feat: apple watch app + home screen widgets#655
Conversation
…-driven relay, remove dead createGroup - Remove the malformed react-native-svg+15.15.2.patch: it captured Android build artifacts (android/build/.transforms, classes.dex, generated BuildConfig.java) rather than source. react-native-svg patching is owned by #645, which supersedes this. - Gate the verbose [nip29] publish/join logs behind __DEV__ via nip29Log / nip29Warn so signed events and relay chatter never reach production logs. - Make the default relay + support group id config-driven via react-native-config (NIP29_RELAY_URL / NIP29_SUPPORT_GROUP_ID), with groups.0xchat.com and the support group as defaults — no longer hard-coded. Also route the provider's relayUrls default prop through NIP29_DEFAULT_RELAY_URL. - Remove the unused createGroup from NostrGroupChatProvider (dead code that duplicated the standalone createNip29Group used by CreateGroupModal); drop the now-unused generateSecretKey / bytesToHex imports. No new type errors (tsc unchanged at 87, all pre-existing). Note: react-native-config may need a jest mock when the suite is revived (ENG-433). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The three derive-from-EventStore effects returned `store.subscribe(...)` directly as the cleanup, but its unsubscribe returns `boolean`, which violates useEffect's `void` cleanup contract (TS2345). Wrap each so the cleanup discards the return. No runtime change (React ignored the value); clears the 3 tsc errors this PR introduced. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Native home-screen widget for iOS (WidgetKit) and Android (AppWidget) in small/medium/large sizes showing the live BTC price plus Scan/Receive quick actions. - Shared price snapshot via App Group (iOS) / SharedPreferences (Android), written by the RN app on each price poll through a WidgetBridge native module, and refreshed independently by the widget via the public realtimePrice GraphQL query so it stays current while the app is closed. - Quick actions deep-link via flash://scan and flash://receive; added those routes to the navigation linking config and a flash:// intent filter on Android MainActivity. - iOS extension sources scaffolded with Xcode setup steps in ios/FlashWidget/SETUP.md (target creation must be done in the GUI). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a native watchOS app and a WidgetKit complication that show a live BTC price on the watch and on any watch face, mirroring the conventions of the existing home-screen widget (ios/FlashWidget). - Watch app (SwiftUI): glanceable price, manual refresh, last-updated, and Scan/Receive quick actions handed off to the iPhone. - Complication (WidgetKit, watchOS 9+): accessoryCircular, inline, rectangular, and corner families. - Self-contained price fetch via the public unauthenticated realtimePrice query (works with the phone away / before login; defaults to USD). - Watch-local App Group group.com.lnflash.watch shares the snapshot between the watch app and its complication. - Phone-side WatchConnectivityBridge RN module pushes the user's display currency to the watch and opens flash:// deep links from watch actions; WatchCurrencySync wires it into the authed tree. - ios/FlashWatch/SETUP.md documents Xcode target creation + wiring (pbxproj edited in the GUI, per repo convention). Closes ENG-459 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… chart - Replace generic SF Symbol icons with Flash logo on widgets, watch app, watch complications, and watch app icon - Add price change badge (+/- percent) with green/red coloring on all widget sizes and watch app - Add 30-day high/low range row on large widget and watch app - Add end-point dot marker on sparkline for current price position - Polish typography spacing and chart proportions to match River quality - Chart data: ONE_MONTH range with smooth Catmull-Rom curves (140 points)
…watch The widget sparkline was using a different price conversion formula than the watch app, causing wrong price values (+82140% change) and a broken flat-line chart. Now uses the same (base / 10^offset) * multiple(currencyUnit) formula as the Flash price-history screen and watch app. Also switched from timestamp-based to index-based x-axis positioning so data points are evenly spaced — matching the watch chart exactly.
…home-screen-widgets
islandbitcoin
left a comment
There was a problem hiding this comment.
⛔ Request changes (posted as a comment — GitHub blocks the request-changes state on your own PR)
Solid feature work — the watch app, complication, and widgets are well organized, and the tooling fixes are sound. But four issues below are merge-blockers in my view: the watch quick actions silently do nothing in their primary use case, the pre-existing LNFlash-Alt target no longer compiles or signs, non-USD users (i.e. JMD — our home market) get corrupted watch charts, and every whole-widget tap lands on the Send screen with an error instead of Home.
Hold merge for:
- Watch quick actions no-op when the phone app is backgrounded (
WatchConnectivityBridge.swift) - LNFlash-Alt target broken — compile + entitlements (
AppDelegate.mm, entitlements) - Mixed-currency history corrupts the watch chart/badge (
PriceService.swiftappend) flash://homeroutes to the Send screen;scan/receiveshadow usernames (deep-link config)
Fast-follow candidates: Android widget render-before-fetch + process death, Float price truncation, complication self-reload, and the native-layer duplication cluster (2× PriceService, 2× sparkline, 3× hardcoded prod endpoint, duplicated paycode builder).
Inline comments have committable suggestions where the fix is local. One thing I explicitly checked and did NOT flag: cold-start deep links are fine — setAppUnlocked re-fetches Linking.getInitialURL() and replays it through the live listener.
| let allowed = ["scan", "receive", "home"] | ||
| guard allowed.contains(action), let url = URL(string: "flash://\(action)") else { return } | ||
| DispatchQueue.main.async { | ||
| UIApplication.shared.open(url, options: [:], completionHandler: nil) |
There was a problem hiding this comment.
Blocker: quick actions do nothing unless the phone app is already foregrounded. When the watch's sendMessage wakes this app in the background, iOS ignores UIApplication.shared.open — third-party apps cannot foreground themselves. The transferUserInfo fallback lands in didReceiveUserInfo and dead-ends in this same call, and the reply handler unconditionally reports received: true, so the failure is invisible on both sides. The comment in PhoneConnectivity.swift ("sendMessage can wake the iOS app in the background") is true, but the woken app still can't present UI.
Suggested approach: check UIApplication.shared.applicationState; if not .active, post a local notification (UNUserNotificationCenter) whose tap launches the deep link, instead of calling open directly. Also pass a real completionHandler and log/reply on failure so the watch can surface it.
| #import <WatchConnectivity/WatchConnectivity.h> | ||
|
|
||
| #import "RNBootSplash.h" | ||
| #import "Flash-Swift.h" |
There was a problem hiding this comment.
Blocker: this unconditional import breaks the LNFlash-Alt target. Alt's Sources phase compiles only AppDelegate.h/.mm + main.m — zero Swift files — so no Flash-Swift.h is ever generated for it and this #import fails with file-not-found. Guarding with __has_include keeps Alt building (the header only resolves in targets that compile the Swift bridge):
| #import "Flash-Swift.h" | |
| #if __has_include("Flash-Swift.h") | |
| #import "Flash-Swift.h" | |
| #endif |
(The cleaner alternative is adding WatchConnectivityBridge.swift/WidgetBridge.swift to the Alt target, but Alt presumably shouldn't ship watch connectivity at all.)
| { | ||
| [RNFBAppCheckModule sharedInstance]; | ||
| [FIRApp configure]; | ||
| [WatchConnectivityBridge activateSession]; |
There was a problem hiding this comment.
Same Alt-target break at the call site — WatchConnectivityBridge is undeclared there:
| [WatchConnectivityBridge activateSession]; | |
| #if __has_include("Flash-Swift.h") | |
| [WatchConnectivityBridge activateSession]; | |
| #endif |
Related signing issue: Alt's CODE_SIGN_ENTITLEMENTS points at the same LNFlash/LNFlash.entitlements / LNFlashDebug.entitlements files this PR adds com.apple.security.application-groups (group.com.lnflash) to, so com.flashapp.alt now requires an app-group-capable provisioning profile. Either give Alt its own entitlements files without the app group, or register the capability for its profile.
| timestamp: ts | ||
| ) | ||
| WatchStore.write(snapshot) | ||
| WatchStore.appendHistory(snapshot) |
There was a problem hiding this comment.
Blocker: this appends display-currency prices into USD history, corrupting the chart for non-USD users. The snapshot is priced in the phone-synced display currency (query uses previous.currencyCode), but priceHistory is filled by fetchHistory with USD-only btcPriceListUnauthed points (no currency arg exists). The complication's getTimeline calls only fetch, so for a JMD user (~155× USD) JMD points steadily accumulate in USD history; ContentView reads the store at launch and whenever the history fetch fails, and its only filter is price > 0 — result: a ~+15,000% change badge and a spiked sparkline. The widget copy of this file already removed the append for exactly this reason ("Don't append to history here — the 30D fetch is the authoritative source").
| WatchStore.appendHistory(snapshot) |
Also worth porting the widget's SharedStore.sanitizeHistory ratio guard into WatchStore.readHistory as defense in depth — the watch currently has no mixed-unit protection at all.
| ) | ||
| WatchStore.write(snapshot) | ||
| WatchStore.appendHistory(snapshot) | ||
| reloadComplications() |
There was a problem hiding this comment.
Self-reload loop: ComplicationProvider.getTimeline calls this same fetch (SETUP.md ticks this file into the complication target), so every complication timeline generation unconditionally requests its own invalidation via reloadComplications() → getTimeline → fetch → repeat. WidgetKit coalesces extension-originated reloads, so in practice this burns the daily refresh budget early and then the complication freezes stale, instead of honoring the .after(20 min) policy.
| reloadComplications() |
Trigger the reload only from the watch-app context instead (e.g. in ContentView.refreshAll after its fetch completes), so the provider path never self-invalidates.
| setTextViewText(R.id.widget_currency, snapshot.currencyCode) | ||
|
|
||
| // Whole-widget tap opens the app; action buttons deep-link to screens. | ||
| setOnClickPendingIntent(R.id.widget_root, deepLinkIntent(context, "flash://home")) |
There was a problem hiding this comment.
Blocker: flash://home opens the Send Bitcoin screen, not Home. There is no home path in the navigation linking config, so it falls through to the pre-existing sendBitcoinDestination: ":payment" catch-all — a whole-widget tap opens the Send screen trying to parse "home" as a payment destination (invalid-destination error, or worse, a real user named home). Same for iOS (FlashWidgetEntryView.swift DeepLink.sendHome) and the watch bridge's home action.
For "just open the app", a plain launch intent is more robust than a deep link here (context.packageManager.getLaunchIntentForPackage(context.packageName) wrapped in the same PendingIntent); alternatively namespace the widget links (see my comment in navigation-container-wrapper.tsx) and add an explicit home mapping.
| scanningQRCode: "scan", | ||
| receiveBitcoin: "receive", |
There was a problem hiding this comment.
These static paths shadow the :payment catch-all — and home isn't mapped at all. React Navigation ranks static segments above param segments regardless of config order, so https://pay.getflash.io/receive (a valid pay link for a user literally named receive — username rules allow it) now opens the local Receive screen instead of the send-to-user flow. Meanwhile the widgets' flash://home has no mapping and falls through to sendBitcoinDestination: ":payment", opening the Send screen with payment="home".
Recommend namespacing the widget quick actions so they can never collide with usernames and the whole family is explicit:
scanningQRCode: "widget/scan",
receiveBitcoin: "widget/receive",
plus updating the three emitters (FlashWidgetProvider.kt, FlashWidgetEntryView.swift DeepLink, WatchConnectivityBridge.handleWatchAction) to flash://widget/..., and either mapping widget/home to Home or using a plain launch intent for the whole-widget tap.
| context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() | ||
| .putFloat(KEY_PRICE, snapshot.btcPrice.toFloat()) | ||
| .putString(KEY_CURRENCY, snapshot.currencyCode) | ||
| .putString(KEY_SYMBOL, snapshot.currencySymbol) | ||
| .putInt(KEY_FRACTION, snapshot.fractionDigits) | ||
| .putFloat(KEY_TIMESTAMP, snapshot.timestamp.toFloat()) | ||
| .apply() |
There was a problem hiding this comment.
Float truncates the price. The pipeline is Double end-to-end (JS bridge getDouble, PriceFetcher math) but persistence narrows to 32-bit Float (~7 significant digits). At JMD scale (~1.7e7/BTC) Float spacing is 2, at IDR scale (~1.8e9) it's 128 — and formattedPrice() renders every digit, so the widget shows visibly wrong trailing digits. iOS stores full Double. Store the raw bits in a long instead (keys are new in this PR, so no migration concern):
| context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() | |
| .putFloat(KEY_PRICE, snapshot.btcPrice.toFloat()) | |
| .putString(KEY_CURRENCY, snapshot.currencyCode) | |
| .putString(KEY_SYMBOL, snapshot.currencySymbol) | |
| .putInt(KEY_FRACTION, snapshot.fractionDigits) | |
| .putFloat(KEY_TIMESTAMP, snapshot.timestamp.toFloat()) | |
| .apply() | |
| context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() | |
| .putLong(KEY_PRICE, snapshot.btcPrice.toRawBits()) | |
| .putString(KEY_CURRENCY, snapshot.currencyCode) | |
| .putString(KEY_SYMBOL, snapshot.currencySymbol) | |
| .putInt(KEY_FRACTION, snapshot.fractionDigits) | |
| .putLong(KEY_TIMESTAMP, snapshot.timestamp.toRawBits()) | |
| .apply() |
| return Snapshot( | ||
| btcPrice = p.getFloat(KEY_PRICE, 0f).toDouble(), | ||
| currencyCode = p.getString(KEY_CURRENCY, "USD") ?: "USD", | ||
| currencySymbol = p.getString(KEY_SYMBOL, "$") ?: "$", | ||
| fractionDigits = p.getInt(KEY_FRACTION, 2), | ||
| timestamp = p.getFloat(KEY_TIMESTAMP, 0f).toDouble(), | ||
| ) |
There was a problem hiding this comment.
Matching read side for the Double-bits storage:
| return Snapshot( | |
| btcPrice = p.getFloat(KEY_PRICE, 0f).toDouble(), | |
| currencyCode = p.getString(KEY_CURRENCY, "USD") ?: "USD", | |
| currencySymbol = p.getString(KEY_SYMBOL, "$") ?: "$", | |
| fractionDigits = p.getInt(KEY_FRACTION, 2), | |
| timestamp = p.getFloat(KEY_TIMESTAMP, 0f).toDouble(), | |
| ) | |
| return Snapshot( | |
| btcPrice = Double.fromBits(p.getLong(KEY_PRICE, 0L)), | |
| currencyCode = p.getString(KEY_CURRENCY, "USD") ?: "USD", | |
| currencySymbol = p.getString(KEY_SYMBOL, "$") ?: "$", | |
| fractionDigits = p.getInt(KEY_FRACTION, 2), | |
| timestamp = Double.fromBits(p.getLong(KEY_TIMESTAMP, 0L)), | |
| ) |
| import Foundation | ||
|
|
||
| enum PriceService { | ||
| static let endpoint = URL(string: "https://api.flashapp.me/graphql")! |
There was a problem hiding this comment.
Duplication cluster (fast-follow, but worth a plan before this ships):
- This file and
FlashWatch Watch App/PriceService.swiftare near-identical (~165 lines: same endpoint, char-identical GraphQL strings, same conversion/sampling) and have already diverged inside this PR (append/sanitize/reload behavior). One sharedPriceService.swiftwith multi-target membership — the pattern SETUP.md already uses forWatchStore.swift— with the store injected per target. - The prod GraphQL URL is hardcoded here, in the watch copy, and in
PriceFetcher.kt, while the app resolves per-instancegraphqlUrifromapp/config/galoy-instances.ts(6 instances). Staging/Test builds will show prod prices in widgets, and a future API host change bricks shipped widgets. The JS sync layer already pushes snapshots natively — push the endpoint too and read it here. - The Catmull-Rom sparkline (
SmoothLineShape/AreaShape/SmoothPath) is duplicated line-for-line betweenFlashWidgetEntryView.swiftand the watchContentView.swift— and only the widget side got thesanitizeHistoryratio guard. app/components/watch-receive-sync/index.tsxre-implements the LNURL paycode builder byte-for-byte fromapp/screens/receive-bitcoin-screen/payment/payment-request.ts(same HRP, limit 1500, same?lightning=composite) — extract a shared helper so the watch QR can't drift from the Receive screen.
- Watch quick actions: iOS forbids background self-foregrounding, so when
the app isn't active the action is stashed (5-min TTL) and surfaced as a
local notification; the deep link fires on next activation. Failures are
now logged instead of silently dropped.
- LNFlash-Alt target: guard Flash-Swift.h import/call with __has_include
(Alt compiles no Swift), and give Alt its own entitlements files without
the new app-group so its provisioning profile still signs.
- Watch chart: stop appending display-currency spot prices into the USD
30-day history (the widget already didn't), and port the widget's
mixed-unit sanitize guard into WatchStore.readHistory. Complication no
longer reloads its own timeline from fetch — the watch app triggers
reloads (ContentView.refreshAll) instead.
- Deep links: namespace quick actions as flash://widget/{scan,receive} so
they can't shadow the :payment username route; whole-widget tap on
Android uses a plain launch intent, iOS uses unmapped flash://widget/home
(opens the app without deep-navigating).
- Android widget: render the cached snapshot synchronously in onUpdate
before the network fetch (receiver process is kill-eligible after
onReceive returns), and persist Doubles as raw bits instead of Float
(visible precision loss at JMD/IDR price scales).
- Extract shared getPaycodeQRContent helper; Receive screen and watch QR
now use one paycode implementation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Pushed Blockers — all fixed:
Fast-follows included: complication no longer self-reloads (watch app triggers reloads from Deliberately deferred (needs Xcode target-membership restructuring, best done in-editor): merging the two PriceService copies / sparkline into shared multi-target files, and pushing the per-instance GraphQL endpoint from JS into the native stores. The hardcoded-prod-endpoint issue stands — suggest a follow-up ticket. Verification: |
|
✅ iOS build verified on a machine with Xcode — that was the last open item from the review fixes in (If the build didn't include the LNFlash-Alt scheme specifically, worth one build of that scheme too — it's the target nobody normally builds, and it's what the |
Summary
Verification
git diff --check origin/main...HEADxcodebuild -workspace ios/LNFlash.xcworkspace -scheme 'FlashWatch Watch App' -configuration Debug -destination 'platform=watchOS Simulator,id=B12E90B2-3F78-4E36-874D-EC1835869416' -derivedDataPath /tmp/lnflash-watch-action-dd buildxcodebuild -workspace ios/LNFlash.xcworkspace -scheme FlashWidgetExtension -configuration Debug -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' -derivedDataPath /tmp/lnflash-watch-action-dd buildxcodebuild -workspace ios/LNFlash.xcworkspace -scheme FlashWidgetExtension -configuration Debug -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' -derivedDataPath /tmp/lnflash-watch-qr-ios-dd buildCurrent CI Notes
tsc -p .with TS5095 under the repo's current TypeScript config.ts-jestcannot load missingttypescript.Governement,Cantacts,Infor, andEnjoingin files outside this watch/widget scope.Notes
yarn tsc:checkwith the existing config hits the same TS5095 CI blocker.module: esnext,moduleResolution: bundler, andts-patchclears the immediate CI config blockers but exposes broad existing TypeScript errors across app/test files, so that config cleanup is intentionally not included in this PR.