feat(coordinator): extract rider protocol logic into :common coordinators#70
feat(coordinator): extract rider protocol logic into :common coordinators#70variablefate merged 9 commits intomainfrom
Conversation
…eRiderCoordinator to :common Extracts rider-side protocol logic from rider-app/RiderViewModel into three focused domain coordinators in :common/coordinator/ as the first step of Issue #65. Each coordinator is SDK-extraction-ready: no Hilt annotations (Issue #52 owns that migration), no Context/ViewModel references, and full KDoc on every public symbol. - AvailabilityMonitorPolicy: pure policy object for pre-confirm driver availability monitoring; adapted from rider-app with RideStage replaced by isWaitingForAcceptance: Boolean for :common compatibility - OfferCoordinator: offer sending (direct/broadcast/RoadFlare), batch offers, pre-confirmation driver monitoring (Issue #22), SharedFlow OfferEvent output - PaymentCoordinator: HTLC lock, Kind 3175 confirmation, escrow-bypass dialog, PIN verification, SAME_MINT preimage share, CROSS_MINT bridge + pending poll, ride completion HTLC marking, post-confirm ack timeout; all CAS race guards from the original ViewModel preserved exactly - RoadflareRiderCoordinator: Kind 3186 key-share listener, Kind 3188 key-ack, Kind 3189 driver-ping stub pending NostrService.publishDriverPing() Closes #65 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…areRiderCoordinator into ViewModels (Issue #65) - rider-app/RiderViewModel: add coordinator fields, setWalletService wires walletService to both PaymentCoordinator and OfferCoordinator; init{} collects all three event flows; handlePaymentEvent/handleOfferEvent/handleRoadflareRiderEvent translate events to UiState; autoConfirmRide() delegates to paymentCoordinator.onAcceptanceReceived(); subscribeToDriverRideState() callback routes through paymentCoordinator.onDriverRideStateReceived(); retryEscrowLock() and cancelRideAfterEscrowFailure() delegate to coordinator; clearRiderStateHistory() and closeAllRideSubscriptionsAndJobs() call paymentCoordinator.reset(); RoadFlare key-share listener started in init{} via roadflareRiderCoordinator; performLogoutCleanup() destroys all three - roadflare-rider/RiderViewModel: add RoadflareRiderCoordinator field, start key-share listener in init{}, destroy in onCleared() - PaymentCoordinator: fix DriverStatusUpdated.status type String (DriverStatusType is a String-constants object, not a Kotlin type) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- RoadflareRiderCoordinator.destroy() was calling scope.cancel() on the passed viewModelScope, which would have killed ALL ViewModel coroutines. Now only stops the key-share subscription. - handlePaymentEvent(PinVerified) was calling revealPreciseDestination() after PaymentCoordinator already revealed destination in handlePinSubmission(), causing a duplicate Kind 30181 DESTINATION action on every correct PIN. - handleRideCompletion() called clearHtlcRideProtected() for the legacy-driver (claimSuccess=null) + SAME_MINT case despite the comment saying "keep LOCKED". Removed the erroneous unlock; coordinator's conservative behaviour now wins. - handleOfferEvent(Accepted) was not calling offerCoordinator.onAcceptanceHandled(), leaving the availability-monitoring subscription open and able to fire false DriverUnavailable dialogs after the ride was confirmed. - handleOfferEvent(InsufficientFunds) was only clearing isSendingOffer and not populating showInsufficientFundsDialog / insufficientFundsAmount etc., making the deposit dialog invisible to the user. - restorePostAcceptanceState() never called paymentCoordinator.restoreRideState(), so after process death the coordinator's dedup sets and PIN context were empty, risking re-processing of already-handled driver events. - Removed dead autoConfirmRideLegacy (and its private helpers escrowFailureMessage, startEscrowRetryDeadline) plus the dead ViewModel-level confirmationInFlight AtomicBoolean that was reset in resetRideUiState() rather than the coordinator's active gate. Also removed three now-unused imports. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Code reviewFound 7 issues (all fixed in 17674a7):
ridestr/rider-app/src/main/java/com/ridestr/rider/viewmodels/RiderViewModel.kt Lines 5369 to 5383 in 17674a7
ridestr/rider-app/src/main/java/com/ridestr/rider/viewmodels/RiderViewModel.kt Lines 4850 to 4866 in 17674a7
ridestr/rider-app/src/main/java/com/ridestr/rider/viewmodels/RiderViewModel.kt Lines 5502 to 5515 in 17674a7
ridestr/rider-app/src/main/java/com/ridestr/rider/viewmodels/RiderViewModel.kt Lines 5532 to 5546 in 17674a7
ridestr/rider-app/src/main/java/com/ridestr/rider/viewmodels/RiderViewModel.kt Lines 1042 to 1060 in 17674a7
🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
- Double post-confirm ack timeout after process-death restore: both the ViewModel's startPostConfirmAckTimeout() and the coordinator's (via paymentCoordinator.restoreRideState) armed independent timers for the same deadline, causing a duplicate cancellation attempt ~60s after restore. Consolidated into the coordinator; the ViewModel computes the effective deadline (covering legacy saves without a persisted deadline) and hands it to paymentCoordinator.restoreRideState. Removed the ViewModel's own startPostConfirmAckTimeout function and postConfirmAckTimeoutJob field. - Missing "Case 2" same-ride cancel guard: autoConfirmRide() in the original ViewModel handled two post-suspension cases after confirmRide() returned — Case 1 (cross-ride: acceptance changed → targeted cancel only) and Case 2 (same-ride: rider cancelled while we were suspended → targeted cancel + author-wide cleanup). The coordinator extraction only kept Case 1. Added PaymentEvent.ConfirmationCancelledBySelf and distinguished the two cases by currentAcceptanceEventId == null, with the ViewModel handler running cleanupRideEventsInBackground() for the self-cancel path. - retryEscrowLock() bypassed the confirmationInFlight CAS gate by calling runConfirmation() directly. A rapid double-tap on "Retry" would launch two concurrent confirmation coroutines, violating the one-confirmation-per-ride invariant. Added the same compareAndSet(false, true) guard used by onAcceptanceReceived. - pingDriver() was a stub that emitted RoadflareRiderEvent.PingSent optimistically without publishing any Kind 3189 event (NostrService does not yet expose publishDriverPing). Callers observing PingSent would incorrectly believe a ping was sent. No callers exist, so removed the stub method, the PingSent event variant, and the ViewModel's dead handler branch. Added a TODO for Issue #52 to re-introduce once the publisher exists. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Code review — Second passFirst pass fixed 7 bugs. Five fresh review agents ran independently against the current HEAD and surfaced more findings. Summary: 4 more real bugs fixed in ec5f01f; 5 findings verified-false or out of scope (details below). Fixed in ec5f01f
Findings that advanced to verification but were not fixed
Verified-false / out of scope
Net change vs first pass: −80 / +77 lines (PaymentCoordinator, RoadflareRiderCoordinator, RiderViewModel). Build will run in CI. 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
After two earlier rounds of fixes, the ViewModel still carried a full shadow of the coordinator's driver-state and payment path: `handleDriverRideState`, `handlePinSubmission`, `handleDepositInvoiceShare`, `executeBridgePayment`, `startBridgePendingPoll`, `handleBridgeSuccessFromPoll`, `checkAndRevealPrecisePickup`, `revealPrecisePickup`, `revealPreciseDestination`, `revealLocation`, `publishPinVerification`, and `sharePreimageWithDriver` — about 800 lines. The Kind 30180 subscription callback now goes directly to `paymentCoordinator.onDriverRideStateReceived`, and all of these ViewModel methods are unreachable. Removed along with their backing fields: `riderStateHistory`, `historyMutex`, `lastReceivedDriverStateId`, `processedDriverStateEventIds`, `currentRiderPhase`, `bridgePendingPollJob`, `escrowRetryDeadlineJob`. Coordinator already owns equivalents and is the live source of truth. `lastProcessedDriverActionCount` was threaded through `paymentCoordinator.restoreRideState(..., lastProcessedDriverActionCount)` / `getLastProcessedDriverActionCount()` so process-death restore still skips replayed Kind 30180 history. Bridge-pending poll after process-death restore now calls `paymentCoordinator.resumeBridgePoll()` (new public method) so the BridgeComplete Kind 30181 is published by the coordinator with the coordinator's own history and transition chain — eliminating the duplicate-history divergence that surfaced with the old ViewModel-owned poll. Also in this pass: - `setHtlcRideProtected` was being called both inside `PaymentCoordinator.runConfirmation()` before emitting `Confirmed` and again from the ViewModel's `handlePaymentEvent(Confirmed)` handler. Removed the ViewModel call — coordinator is authoritative. - The post-`confirmRide()` stale-ride comment claimed `confirmationInFlight stays true` for both cases, but it is already false for the self-cancel case (because `resetInternalState()` ran). Comment rewritten to describe both cases accurately. - Dropped unused imports (`RiderRideAction`, `RiderRideStateEvent`, `MeltQuoteState`, `kotlinx.coroutines.sync.withLock`). Net: RiderViewModel.kt shrinks from ~5800 to ~4977 lines. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Code review — Third passFirst two passes fixed 11 bugs across commits Fixed in 145605a
Verified-false / out of scope
Convergence checkThree passes have now fixed 18 issues total (7 + 4 + 7). The remaining open items are all either:
I think another full pass would likely find at most one or two more small issues. Calling this the convergence point; further cleanup belongs in follow-up PRs tracked by Issue #52. Net change this pass: −850 / +52 lines. 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
This pass targeted structural entropy rather than new bugs. Four passes of review have now converged on clean contracts; this commit tightens the final loose ends. - Deleted dead `handleDriverArrived()` (~30 lines). The method was orphaned by the coordinator migration: it had no callers, the Kind 30180 ARRIVED status is now handled by `handleDriverStatusAction` via `PaymentEvent.DriverStatusUpdated`. - Deleted `RoadflareRiderCoordinator.sendKeyAck()`. No callers anywhere; the live Kind 3188 ack flow (stale-key refresh request) still goes directly via `NostrService.publishRoadflareKeyAck()` from MainActivity / RoadflareTab. Left a TODO(#52) for when those call sites migrate to the coordinator. - Deleted `PaymentCoordinator.getAndClearRideEventIds()` and the backing `rideEventIds` accumulator. No callers. Author-wide NIP-09 cleanup (`nostrService.backgroundCleanupRideshareEvents`) catches all rideshare events by the user's pubkey, so the parallel tracking list was dead. - Deleted the private `data class FareCalc` shadow in RiderViewModel.kt. The file already imports `com.ridestr.common.coordinator.FareCalc` (same shape), but the local declaration won for unqualified references — meaning the import was dead and internal fare values couldn't be passed to the coordinator without conversion. Deleting the shadow collapses 20+ internal usages onto the one canonical type. - Removed the no-op `offerCoordinator.onAcceptanceHandled()` call from `handleOfferEvent(Accepted)`. `OfferCoordinator` has no live offer-sending callers today, so `OfferEvent.Accepted` never fires from the coordinator — the hook was closing subscriptions the coordinator never opened. Replaced with a comment explaining it will come back when Issue #52 migrates offer send. Coordinator method stays. - Renamed `clearRiderStateHistory()` → `clearRideCoordinatorState()` with an updated KDoc. After pass 3 removed the ViewModel's history fields, the old name lied to every reader about what was being cleared. - Unified the two copies of `AvailabilityMonitorPolicy`. Deleted the rider-app copy (used `stage: RideStage`), made the common copy public, migrated RiderViewModel to use `isWaitingForAcceptance = stage == WAITING_FOR_ACCEPTANCE`. Dropped the `SHOW_UNAVAILABLE` enum variant from the common copy — it was never returned by either decision function; the `when` arms in OfferCoordinator and RiderViewModel existed only as exhaustive-match placeholders. Moved the test to `common/src/test/java/com/ridestr/common/coordinator/` with `isWaitingForAcceptance` assertions, covering the equal-timestamp case that was previously untested. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Code review — 4th PassPrior passes fixed 18 bugs across 3 commits ( Fixed in
|
Adds direct unit tests for the public `PaymentCoordinator` API surface that four rounds of review passes introduced or clarified: - Cancellation dedup roundtrip: `markCancellationProcessed` / `isCancellationProcessed` / `reset` contract. - Process-death roundtrip: `restoreRideState(lastProcessedDriverActionCount)` → `getLastProcessedDriverActionCount()`, including the default-zero case and the "last-write-wins" semantics of sequential restores. - Reset + cancel idempotency: multiple calls never throw and leave the coordinator in a clean state. - `retryEscrowLock` stale-UI guard: returns cleanly when no retry is pending. The confirmation coroutine itself exercises `NostrService` + `WalletService` and is covered integration-style through the ViewModel; this test file pins only the pure state contracts that live entirely inside the coordinator, which is where the prior passes' fixes actually landed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Follow-up — applying the filter to deferred itemsRe-examining the four deferred items from pass 4 against the actual criteria (lowers entropy / improves codebase / fixes real bug): 1. Logger abstraction — VERIFIED FALSE blockerAgent #3 flagged 2.
|
| Item | Filter result | Action |
|---|---|---|
| Logger abstraction | False-positive | Skipped |
| DriverViewModel decomp | Out of scope | Deferred |
| Coordinator test coverage | Real | Fixed (aa0478a) |
| Offer-send migration | Real | Attempted, reverted, concrete plan filed |
Four passes + this follow-up: 25 items addressed, 1 test file added, 1 item deferred with a clear exit plan. The PR is ready for merge on coordinator-extraction grounds; offer-send wiring is the next issue.
🤖 Generated with Claude Code
Gaming out the ride-completion path revealed five real entropy issues still on the branch after four prior passes. All traceable back to the coordinator extraction leaving "belt and suspenders" duplication between VM and coordinator for the final status flow. 1. Duplicate HTLC mark/clear: `PaymentCoordinator.handleCompletion()` already calls `markHtlcClaimedByPaymentHash` or `clearHtlcRideProtected` (per driver's claimSuccess) before emitting `DriverCompleted`. The ViewModel's `handleRideCompletion()` re-ran the identical if/else chain on the same payment hash. Idempotent but duplicated logic. 2. Duplicate `walletService.refreshBalance()`: coordinator calls it, ViewModel re-called it in the follow-up coroutine. One extra NIP-60 round-trip per completion for no benefit. 3. Synthetic `DriverRideStateData` construction in `handlePaymentEvent(DriverCompleted)`: the handler built a fake DriverRideStateData just so `handleRideCompletion(statusData)` could dig out `statusData.finalFare` and `statusData.history` for the HTLC logic. Once the HTLC logic moves entirely to the coordinator, only `finalFare` is needed, so we can pass the Long directly. 4. Dead `COMPLETED` and `CANCELLED` branches in `handleDriverStatusAction()`: the coordinator routes COMPLETED via `PaymentEvent.DriverCompleted` and CANCELLED via `DriverCancelled` — neither reaches `DriverStatusUpdated`. Those VM `when` arms were unreachable. Removed. 5. `PaymentEvent.DriverCompleted.claimSuccess` and `PaymentEvent.DriverStatusUpdated.driverState` were public event fields with no remaining consumer after the above simplifications. Dropping them shrinks the coordinator's public contract. Cascading simplifications: - `handleRideCompletion` now takes `(finalFareSats: Long?)` — no wrapper. - `handleDriverStatusAction` now takes `(status: String, confirmationEventId: String)` — no `action` / `driverState` wrapper. - Imports `DriverRideAction` and `DriverRideStateData` are dropped from the ViewModel entirely. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Code review — 5th Pass (entropy audit)The question this pass: is the refactored code lower-entropy than what it replaced? I gamed out the completion flow end-to-end and found five real issues — all tied to "belt and suspenders" duplication between VM and coordinator that prior passes missed because each duplicate was individually harmless (idempotent / unreachable / aesthetic). Fixed in
Cascading simplifications that fell out:
Net for this pass: Entropy snapshot across all 5 passes:
The remaining entropy source is 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
Prior passes de-duplicated the completion flow. This pass found parallel duplication in the failure/cancellation paths: - DriverCancelled (Kind 30180 with status=CANCELLED) was unlocking HTLC THREE times per cancel: once in PaymentCoordinator.handleDriverStatus before emitting, once directly in RiderViewModel.handleDriverCancellation, and once via paymentCoordinator.onRideCancelled(). All three are idempotent but structurally redundant. - MaxPinAttemptsReached (3 wrong PIN attempts) was unlocking TWICE: once in PaymentCoordinator.handlePinSubmission before emitting, once in the ViewModel handler. - clearRide (rider-initiated cancel) was unlocking directly via walletService.clearHtlcRideProtected(), but NOT via onRideCancelled, leaving the state-sync-via-cancellation pattern inconsistent with the event-driven paths. Fix: single authoritative HTLC-unlock path = paymentCoordinator.onRideCancelled(). - Removed the pre-emit clears from PaymentCoordinator on the CANCELLED and MaxPinAttemptsReached branches. - Replaced the ViewModel's direct clearHtlcRideProtected() calls in handleDriverCancellation, MaxPinAttemptsReached handler, and clearRide with paymentCoordinator.onRideCancelled(). - Left PaymentCoordinator.handleCompletion's claimSuccess==false branch intact — that's a completion-path unlock for rider refund, not a cancellation, and it's the only caller in that branch. Now: every ride-ending path (driver cancel, driver status=CANCELLED, Kind 3179, bridge fail, post-confirm ack timeout, rider cancel, PIN brute force, escrow retry timeout) routes through onRideCancelled() for HTLC state transitions. Single source of truth, no divergence. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Code review — 6th Pass (final convergence)Five prior passes closed 25+ findings. This pass targeted the failure/cancellation paths specifically — prior passes had de-duplicated the happy path (completion), but the parallel duplication on the cancel side was missed. Fixed in
|
| Path | Calls before | Calls after |
|---|---|---|
Driver publishes Kind 30180 status=CANCELLED |
3 (coord pre-emit + VM direct + onRideCancelled) |
1 (onRideCancelled) |
MaxPinAttemptsReached (3 wrong PINs) |
2 (coord pre-emit + VM direct) | 1 (onRideCancelled) |
Rider taps cancel (clearRide) |
1 (VM direct), but inconsistent with event-driven paths | 1 (onRideCancelled) |
| Kind 3179 driver cancellation | 2 (VM direct + onRideCancelled) |
1 (onRideCancelled) |
BridgeFailed → handleDriverCancellation |
2 (VM direct + onRideCancelled) |
1 (onRideCancelled) |
PostConfirmAckTimeout → handleDriverCancellation |
2 (VM direct + onRideCancelled) |
1 (onRideCancelled) |
All idempotent but structurally redundant. Consolidated to a single authoritative HTLC-unlock path: paymentCoordinator.onRideCancelled() (coordinator line 395). Removed:
PaymentCoordinator.handleDriverStatusCANCELLED branch pre-emit clearPaymentCoordinator.handlePinSubmissionMaxPinAttemptsReached pre-emit clearRiderViewModel.handleDriverCancellationdirect clear (line 4056)RiderViewModel.handlePaymentEvent(MaxPinAttemptsReached)direct clear (lines 4463-4465)RiderViewModel.clearRidedirect clear (line 3028) — replaced withonRideCancelled
Left alone: PaymentCoordinator.handleCompletion's claimSuccess == false branch (line 827). That's the completion path's unlock for rider refund when the driver's claim fails — a different semantic than cancellation, single-caller, correct.
Verified-false / out-of-scope
- Agent Driver app needs wallet setup screen in onboarding flow #1 — Test divergence (
RideCompletionClaimTestasserts CLEAR_PROTECTION fornull+SAME_MINT): pre-existing test bug, not introduced by this PR. Test'sdecideHtlcAction()helper is wrong relative to the coordinator's correct behavior. Flagged for follow-up but not fixing here (test cleanup is its own concern). - Agent Lightning address withdrawals don't prompt for amount #3 —
DepositInvoiceReceived.amountdropped: the coordinator logs the amount and uses it internally forexecuteBridgePayment; no UI component renders "X sats to driver's mint" (never did onmaineither). Not a real regression. - Agent Lightning address withdrawals don't prompt for amount #3 — Removing
claimSuccessfromDriverCompletedmasks a failed-claim UX banner: verified false — the pre-refactor code didn't show a banner either. It would be a new feature, not a regression. - Agent Bitcoin price service fetches price twice every 5 minutes #5 —
MaxPinAttemptsReacheddoesn't publish Kind 3179 to driver: pre-existing onmain; driver relies on its own confirmation timeout. Legitimate UX gap but not introduced by this PR. - Agent Bitcoin price service fetches price twice every 5 minutes #5 —
ConfirmationStalemissingmarkCancellationProcessed: verified false.markCancellationProcessedtracks INCOMING cancellations;ConfirmationStalehandler publishes an OUTGOING Kind 3179. Wrong direction. - Agent Transaction ledger missing mint fees, causing balance discrepancy #4 — All 10 CLAUDE.md invariants: verified intact.
Final snapshot
| Pass | Fixes landed | Commit |
|---|---|---|
| 1 | 7 bugs | 17674a7 |
| 2 | 4 bugs | ec5f01f |
| 3 | ~820 lines shadow code | 145605a |
| 4 | 7 entropy items | 4fd7577 |
| 5 | 5 completion-flow duplications | bc2269a |
| 6 | HTLC unlock consolidation (this) | a6117b4 |
Plus aa0478a — PaymentCoordinatorTest.kt (11 tests pinning the coordinator's public contracts).
Net across all passes: ~30 items fixed. RiderViewModel.kt = 4871 lines (−860 vs main), with payment/roadflare/coordination logic in focused coordinators with minimal public contracts. One single HTLC-unlock path. One test file pinning the hardest invariants.
This feels like convergence. Further passes would find pre-existing quirks on main that aren't this PR's to fix.
🤖 Generated with Claude Code
- If this code review was useful, please react with 👍. Otherwise, react with 👎.
Updates the Quick Implementation Status table for the #65/#66/#67 arc that landed in PRs #70, #69, #68, #73, and adds two new subsections to Key Files Reference: - Coordinators (:common/coordinator/) — catalogs the six extracted coordinators, notes the SDK-grade posture, flags them as synthetic hotspots warranting extra care on first bug fixes. - Screen Components (Issue #67) — catalogs the per-module components/ packages that now own the decomposed Compose screens. Also annotates the Ride State Management entries to note that ride protocol logic has moved out of the ViewModels into the coordinators (accept* methods are now delegates). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: reflect post-refactor coordinator + screen-component architecture Updates the Quick Implementation Status table for the #65/#66/#67 arc that landed in PRs #70, #69, #68, #73, and adds two new subsections to Key Files Reference: - Coordinators (:common/coordinator/) — catalogs the six extracted coordinators, notes the SDK-grade posture, flags them as synthetic hotspots warranting extra care on first bug fixes. - Screen Components (Issue #67) — catalogs the per-module components/ packages that now own the decomposed Compose screens. Also annotates the Ride State Management entries to note that ride protocol logic has moved out of the ViewModels into the coordinators (accept* methods are now delegates). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: show placeholder in SATS mode when BTC price is unavailable (#72) In SATS display mode, `formatFareAmount` (RideTabComponents) and `formatFareUsd` (DriverSelectionScreen) silently fell back to a `$X.XX` USD string when `BitcoinPriceService.btcPriceUsd` was null. To the user — who had explicitly toggled SATS — the fare surface looked identical to USD mode, so tapping the currency toggle (USD → SATS → USD) produced no visible change and the click target appeared broken. Cold starts, relay outages, and network blips all hit this path. Replaces both fallbacks with `formatSatsOrPlaceholder`, a new helper in :common/fiat/FiatFareFormatting that returns `"— sats"` (em-dash) when the sats value is null. The placeholder is visually distinct from any `$X.XX` formatting — users can see their toggle was applied and understand conversion is pending instead of mistaking the output for a dollar amount. `HistoryStatsCard.totalSpentDisplay` and `RideHistoryEntry.formatFareDisplay` do not have this bug — their SATS branches read a known-present `fareSats` field directly and never cross-format into USD. Closes #72 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * perf: reduce recomposition cost in DriverMode offer cards (#71) Addresses the five named hotspots from Issue #71's third-pass review of #68: 1. **Ticker hoisted to leaf.** `RelativeTimeText(timestampSeconds, ...)` is a new private composable that owns the 1Hz ticker's state. `RideOfferCard` and `BroadcastRideRequestCard` now render it once in their header; the ticker's per-second recomposition is scoped to the `Text` itself instead of cascading through the whole card body (route math, earnings, payment-method lookup). 2. **Derived values wrapped in `remember`.** The nullable route unboxing (`pickupDistanceKm`, `pickupDurationMin`, etc.), the authoritative-USD-fare extraction, the earnings `$/hr` + `$/mi` computations, and the RoadFlare payment-method `findBestCommonFiatMethod` scan all now run only when their real inputs change. Earnings logic factored into `computeEarningsDisplay()` + `EarningsDisplay` so both cards share one `remember` block instead of duplicating the keying. Payment-match state factored into a `PaymentMatchDisplay` sealed class so the allocation-heavy method lookup runs once per offer/methods change. 3. **`items { }` lambdas hoisted.** Each accept/decline lambda in `OfferInbox` and `RoadflareFollowerList` is `remember(offer, callback) { ... }` so child cards see the same lambda identity across recompositions. `items(..., key = { it.eventId })` added so LazyList stays aligned with underlying event IDs. The fiat-offer acceptance guard (Issue #46 interception) factored into `handleOfferAccept()` to avoid duplicating the allocation + scan logic across two call sites. 4. **Data classes annotated `@Immutable`.** `RideOfferData`, `BroadcastRideOfferData`, `FiatFare`, `RouteResult`, and `FollowedDriver` all carry `List<T>` fields or nested lists that cause the Compose compiler to infer unstable-by-default. Each is a pure value type (parsed from a Nostr event, never mutated) passed through Compose UI, so the annotation is safe and unlocks child skipping for the offer cards and follower-list rows once all other inputs stabilise. 5. **HistoryComponents and DriverCard.** Applied the same `remember(deps)` pattern to `HistoryEntryCard.fareDisplay` / `pickupDisplay` / `dropoffDisplay`, the `HistoryStatsCard.totalSpentDisplay` reducer, and `DriverCard.distanceMiles` — these shared the hotspot pattern even though they're not on the 1Hz critical path. Not fixed in this PR: Map/List parameter stability at the OfferInbox / RoadflareFollowerList boundary. kotlin.collections.Map is intrinsically unstable; fixing that requires either adding `kotlinx.collections.immutable` (a cross-cutting dependency) or defining `@Immutable` wrapper classes for every Map/List param in the UiState. Out of scope for a single-issue perf pass — filed as a follow-up observation in the PR body. Closes #71 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: contextual cancel-warning text for fiat/RoadFlare rides (#59) The rider-side cancel warning dialog (shown when cancelling after PIN verification) always said "Your payment has already been authorized to the driver. If you cancel now, the driver can still claim the fare." — but that only applies to Cashu HTLC rides (SAME_MINT / CROSS_MINT payment paths). Fiat payment rides (Zelle, Venmo, Cash App, cash) and RoadFlare bitcoin/fiat rides have no escrow for the driver to claim, so the escrow-claim language was misleading. `CancelDialogStack` now takes the ride's `PaymentPath` and branches: - **Cashu HTLC (SAME_MINT / CROSS_MINT):** unchanged — "Payment Already Sent / driver can still claim the fare / cancelling does not guarantee a refund." - **Fiat / NO_PAYMENT:** "Cancel This Ride? / Your driver has been notified and may already be on the way. Cancelling now may inconvenience them." No refund/claim language since there was no payment authorization. Trigger condition (`preimageShared || pinVerified`) is unchanged — `pinVerified` still surfaces the dialog for fiat rides, just with the correct text. Closes #59 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * review-fix: stabilize parent lambdas + drop Triple in cancel dialog First-pass review feedback on PR #75. Two fixes bundled: **1. Stabilize per-offer callbacks at `DriverModeScreen`.** The #71 changes added `remember(offer, onAcceptOffer) { { onAcceptOffer(offer) } }` wrappers on child-side lambdas in `OfferInbox` / `RoadflareFollowerList` to enable `items { }` child skipping. But the parent passed fresh `{ viewModel.acceptOffer(it) }` lambdas per recompose, so the child `remember` invalidated every frame — strictly worse than inline lambdas. Hoist six per-offer/per-broadcast callbacks to `remember(viewModel) { ... }` at the screen level. `viewModel` is the only key needed (ViewModel instances outlive recomposition), so these stay identity-stable across frames and the child-side `remember`s now actually hold. Unblocks the intended child-skip path in the offer inbox. **2. Replace `Triple` destructuring with direct `val` assignments in `CancelDialogStack`.** Positional destructuring (`val (title, body, footer) = ...`) communicates nothing about what each position means and allocates a `Triple` on every recomposition of the dialog. Replace with three explicit `val title: String` / `val body: String` / `val footer: String?` declarations assigned inside the if/else — clearer and no allocation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: variablefate <variablefate@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Extracts rider-side protocol logic from the 5,731-line
rider-app/RiderViewModelgod-object into three focused domain coordinators in:common/coordinator/, as specified in Issue #65.New files in
:common/coordinator/:AvailabilityMonitorPolicy.kt— pure policy object for pre-confirmation driver availability monitoring; adapted fromrider-appwithRideStagereplaced byisWaitingForAcceptance: Booleanfor:commoncompatibilityOfferCoordinator.kt— offer sending (direct / broadcast / RoadFlare), batch RoadFlare with proximity ordering, pre-confirmation driver monitoring (Issue Update rider UI when selected driver goes offline #22),SharedFlow<OfferEvent>outputPaymentCoordinator.kt— HTLC lock, Kind 3175 confirmation, escrow-bypass retry dialog, PIN verification, SAME_MINT preimage share, CROSS_MINT Lightning bridge + pending poll, ride-completion HTLC marking, post-confirm ack timeout (60 s); all AtomicBoolean CAS race guards from the original ViewModel preserved exactlyRoadflareRiderCoordinator.kt— Kind 3186 key-share listener, Kind 3188 key-ack, Kind 3189 driver-ping stub (pendingNostrService.publishDriverPing())Binding constraints respected:
@Hilt/@Singleton/@Injectannotations (Issue Migrate SettingsManager to Hilt DI + DataStore + Repository pattern #52 owns the DI migration); each coordinator has// TODO(#52): convert to @Singleton @InjectRideSession,DriverInfo,ChatMessage,RouteResultleft in current packages (no moves, no conflicts with parallel Decompose high-churn Compose screens into focused components #67 session)ContextorViewModelin constructor — every coordinator is unit-testable without a ViewModelinternalon implementation detailsRemaining steps (this PR)
rider-app/RiderViewModel.kt— ViewModel becomes thin state-composition layerroadflare-rider/RiderViewModel.ktto consume common coordinators (adapter pattern — keep existing types stable)Test plan
:commonmodule compiles with./gradlew :common:compileDebugKotlin./gradlew :rider-app:assembleDebug🤖 Generated with Claude Code
Closes #65