Skip to content

feat: apple watch app + home screen widgets#655

Open
islandbitcoin wants to merge 19 commits into
mainfrom
feat/apple-watch-app-home-screen-widgets
Open

feat: apple watch app + home screen widgets#655
islandbitcoin wants to merge 19 commits into
mainfrom
feat/apple-watch-app-home-screen-widgets

Conversation

@islandbitcoin

@islandbitcoin islandbitcoin commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Adds the Flash Apple Watch app and BTC price complication with live BTC price, 30-day chart, Flash branding, refresh state, Scan quick action, and Receive Paycode QR display on the watch.
  • Adds iOS home-screen WidgetKit support with BTC price, 30-day watch-style sparkline, stale chart-data cleanup, and Scan/Receive quick actions.
  • Adds Android home-screen widget support and native bridge wiring so widget/watch state stays synced from the React Native app.
  • Wires WatchConnectivity, app-group entitlements, Xcode targets/embed settings, and CocoaPods fixes needed for clean native builds.

Verification

  • git diff --check origin/main...HEAD
  • xcodebuild -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 build
  • xcodebuild -workspace ios/LNFlash.xcworkspace -scheme FlashWidgetExtension -configuration Debug -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' -derivedDataPath /tmp/lnflash-watch-action-dd build
  • xcodebuild -workspace ios/LNFlash.xcworkspace -scheme FlashWidgetExtension -configuration Debug -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' -derivedDataPath /tmp/lnflash-watch-qr-ios-dd build

Current CI Notes

  • Passing on latest checked run before cleanup: CodeQL and Conventional commits.
  • Failing: Check Code stops at tsc -p . with TS5095 under the repo's current TypeScript config.
  • Failing: Tests stop before running test cases because ts-jest cannot load missing ttypescript.
  • Failing: Spell Check reports repo-wide existing typos such as Governement, Cantacts, Infor, and Enjoing in files outside this watch/widget scope.

Notes

  • Local yarn tsc:check with the existing config hits the same TS5095 CI blocker.
  • A local experiment with module: esnext, moduleResolution: bundler, and ts-patch clears 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.

abh3po and others added 18 commits June 12, 2026 15:30
…-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.

@islandbitcoin islandbitcoin left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Watch quick actions no-op when the phone app is backgrounded (WatchConnectivityBridge.swift)
  2. LNFlash-Alt target broken — compile + entitlements (AppDelegate.mm, entitlements)
  3. Mixed-currency history corrupts the watch chart/badge (PriceService.swift append)
  4. flash://home routes to the Send screen; scan/receive shadow 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)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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):

Suggested change
#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];

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same Alt-target break at the call site — WatchConnectivityBridge is undeclared there:

Suggested change
[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)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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").

Suggested change
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()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()getTimelinefetch → 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.

Suggested change
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"))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +88 to +89
scanningQRCode: "scan",
receiveBitcoin: "receive",

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +48 to +54
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()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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):

Suggested change
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()

Comment on lines +38 to +44
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(),
)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Matching read side for the Double-bits storage:

Suggested change
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")!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplication cluster (fast-follow, but worth a plan before this ships):

  1. This file and FlashWatch Watch App/PriceService.swift are 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 shared PriceService.swift with multi-target membership — the pattern SETUP.md already uses for WatchStore.swift — with the store injected per target.
  2. The prod GraphQL URL is hardcoded here, in the watch copy, and in PriceFetcher.kt, while the app resolves per-instance graphqlUri from app/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.
  3. The Catmull-Rom sparkline (SmoothLineShape/AreaShape/SmoothPath) is duplicated line-for-line between FlashWidgetEntryView.swift and the watch ContentView.swift — and only the widget side got the sanitizeHistory ratio guard.
  4. app/components/watch-receive-sync/index.tsx re-implements the LNURL paycode builder byte-for-byte from app/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>
@islandbitcoin

Copy link
Copy Markdown
Contributor Author

Pushed 4baafb44 addressing the review:

Blockers — all fixed:

  1. Watch quick actions in background — 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 via a didBecomeActiveNotification observer (no notification-delegate wiring needed, so no conflict with Firebase swizzling). Foreground path now logs open failures.
  2. LNFlash-Alt target__has_include guards around the Swift header import and activateSession call; Alt now signs with its own LNFlashAlt{,Debug}.entitlements (pre-PR content, no app group).
  3. Mixed-currency watch history — removed the display-currency appendHistory from fetch, ported the widget's ratio-guard sanitizeHistory into WatchStore.readHistory (self-heals any already-contaminated cache), and removed the dead appendHistory function.
  4. Deep links — quick actions namespaced to flash://widget/{scan,receive} (config + all three emitters + SETUP.md); whole-widget tap uses a plain launch intent on Android and unmapped flash://widget/home on iOS, so nothing routes into :payment anymore.

Fast-follows included: complication no longer self-reloads (watch app triggers reloads from refreshAll); Android onUpdate renders the cached snapshot synchronously before fetching; WidgetStore persists Doubles as raw bits; shared getPaycodeQRContent helper extracted (Receive screen + watch QR).

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: yarn check-code + full jest (159 passed) locally; :app:compileDebugKotlin compiles; Swift changes parse but this machine has no Xcode — the iOS workspace needs a build on a machine with Xcode before merge (watch scheme + main app + Alt scheme to confirm the entitlements/guard fixes).

@islandbitcoin

Copy link
Copy Markdown
Contributor Author

✅ iOS build verified on a machine with Xcode — that was the last open item from the review fixes in 4baafb44. With CI green (all 7 checks) and the native build confirmed, all four review blockers are now fixed and verified; the deferred dedup/endpoint work is tracked in ENG-480. Good to merge from the review's perspective.

(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 __has_include guards + new LNFlashAlt{,Debug}.entitlements exist to fix.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants