refactor(drivestr): extract AvailabilityCoordinator, AcceptanceCoordinator, RoadflareDriverCoordinator#69
Conversation
…l (Issue #66) - Add AvailabilityCoordinator (Kind 30173 broadcasting loop, NIP-09 deletion, throttle) - Add AcceptanceCoordinator (offer/broadcast accept, first-acceptance-wins CAS, PaymentPath) - Add RoadflareDriverCoordinator (state sync union-merge, Kind 30014 broadcasting, offline publish) - Consolidate PaymentStatus into :common/payment/PaymentModels (was unused there, now HTLC claim enum) - Wire all three coordinators into DriverViewModel; remove ~200 lines of protocol logic from VM Closes #66 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…pAcceptedRide Removes redundant PaymentPath.determine() call — the coordinator already computed it using its own wallet snapshot. Forwarding the value prevents potential drift if the two call sites ever capture different mint URL state. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Code reviewFound 1 issue:
🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
- AcceptanceCoordinator: capture walletServiceProvider() once per method (was called twice, risking inconsistent pubKey/mintUrl pair) - AvailabilityCoordinator: only clear publishedEventIds on successful NIP-09 deletion (regression — original code cleared only on success) - AvailabilityCoordinator: add trackPublishedEvent() for one-shot publishes (fixes orphaned RoadFlare presence event that was never NIP-09 deleted) - DriverViewModel: track presence event ID via trackPublishedEvent() in goRoadflareOnly() so it is cleaned up on go-offline - RoadflareDriverCoordinator: guard two verbose log lines with BuildConfig.DEBUG (matches existing pattern in common module) - DriverViewModel: restore throttle log detail (time/location values) for observability when debugging location update suppression Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- AcceptanceCoordinator: reset hasAcceptedBroadcast gate on CancellationException so coroutine cancellation mid-acceptance does not permanently block future broadcast offers - DriverViewModel: call acceptanceCoordinator.resetBroadcastGate() unconditionally in handleConfirmationTimeout() — previously only called inside if (location != null), leaving the gate stuck true when location is null at timeout time - AvailabilityCoordinator: revert deleteAllAvailabilityEvents() to unconditional clear — "retain for retry" comment was misleading since clearBroadcastState() always cleared the list immediately after anyway and no retry mechanism exists - DriverViewModel: remove redundant stopBroadcasting() before startBroadcasting() in handleLocationUpdate() — startBroadcasting() cancels the running job internally per its KDoc - PaymentModels: remove misplaced TODO(#52) from PaymentStatus enum (enums are never Hilt-injected) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Code review — Second passFound 5 issues. First pass already fixed 6; these are new findings from the improved state. Advanced (verified real, fixed in commit 5be8340):
ridestr/drivestr/src/main/java/com/drivestr/app/viewmodels/DriverViewModel.kt Lines 2584 to 2596 in 5be8340
ridestr/drivestr/src/main/java/com/drivestr/app/viewmodels/DriverViewModel.kt Lines 1148 to 1152 in 5be8340
Verified false positive / pre-existing (not actioned):
🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
Code review — Third passNo new findings worth acting on. The extraction is clean. Checked at HEAD Investigated and cleared:
Summary across all three passes: 11 real issues caught and fixed (6 in pass 1, 5 in pass 2). Third pass finds the codebase at steady state for this change. 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
- AcceptanceCoordinator: replace nullable AcceptanceResult return with AcceptBroadcastOutcome sealed type. The CAS gate block and the Nostr publish failure were both surfacing as null, so the caller showed "Failed to accept ride request" on duplicate-blocked invocations (e.g. multi-relay delivery race or rapid double-tap) even though the winning call succeeded. Now DuplicateBlocked is a silent outcome and only a genuine PublishFailed surfaces to the user. - RoadflareDriverCoordinator: remove dead scope: CoroutineScope constructor parameter. The field was stored but never referenced; the coordinator's work is done inside suspend functions and the wrapped RoadflareLocationBroadcaster owns its own internal scope. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Code review — Fourth pass (Opus)Passes 1-3 used Sonnet agents; this pass re-ran with Opus. Found 2 new real issues. Advanced (verified real, fixed in commit eedd4b4):
ridestr/drivestr/src/main/java/com/drivestr/app/viewmodels/DriverViewModel.kt Lines 3664 to 3691 in eedd4b4
Verified false positive / pre-existing (not actioned):
Summary across all four passes: 13 real issues caught and fixed (6 + 5 + 0 + 2). PR converged. 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
Pre-finalization improvements on top of four review passes:
1. Replace AvailabilityCoordinator.trackPublishedEvent() with a track:
Boolean parameter on publishAvailability(). Eliminates the two-step
"publish then optionally track" pattern that existed only to work
around one call site (goRoadflareOnly presence event).
2. Move stageBeforeRide from a loose ViewModel var into DriverRideSession
so it is auto-reset by construction via resetRideUiState(). Eliminates
5 scattered `stageBeforeRide = null` reset sites that CLAUDE.md's
"Consolidated State Resets" rule explicitly prohibits. Converts the 3
remaining set sites to updateRideSession { copy(...) }.
3. Remove redundant null checks in handlePreimageShare that the compiler
flagged as always-true/always-false (preimage is non-null after the
early return; preimageShare.preimageEncrypted is a non-null String).
4. Add unit tests for all three coordinators (33 tests):
- AcceptanceCoordinatorTest: CAS gate Success/DuplicateBlocked/
PublishFailed outcomes, CancellationException gate reset, PaymentPath
derivation for SAME_MINT / CROSS_MINT / FIAT_CASH.
- AvailabilityCoordinatorTest: throttle guards, publishAvailability
track=true/false semantics, deleteAllAvailabilityEvents clear-on-
both-paths, clearBroadcastState resetting all three fields.
- RoadflareDriverCoordinatorMergeTest: mergeFollowerLists union,
approved OR, keyVersionSent max-with-clamp, addedAt min, remote-
newer pruning, local-newer retention; mergeMutedLists union + no
auto-unmute.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
Closes #66.
Summary
Decomposes
DriverViewModelinto three shared domain coordinators extracted to:common/coordinator/. The coordinators handle availability broadcasts, offer acceptance with wallet-pubkey handshake and payment-path derivation, and RoadFlare driver state sync. Consolidates the driver-app-localPaymentStatusenum with the shared production values incommon/payment/PaymentModels.kt.Coordinators
AvailabilityCoordinator(:common/coordinator/) — Kind 30173 periodic broadcast loop, NIP-09 batch deletion on go-offline/ride-accept, time + distance throttle guards, and one-shot publish with optional event-ID tracking (track: Boolean).AcceptanceCoordinator(:common/coordinator/) — direct + broadcast offer acceptance. Kind 3174 publish, wallet pubkey / mint URL snapshot,PaymentPathderivation, and broadcast first-acceptance-wins CAS gate. Returns the sealedAcceptBroadcastOutcome(Success / DuplicateBlocked / PublishFailed) so callers can distinguish a silent dedup from a real publish failure.RoadflareDriverCoordinator(:common/coordinator/) — union-merge Kind 30012 state sync,mergeFollowerLists/mergeMutedLists,RoadflareLocationBroadcasterlifecycle, and final OFFLINE Kind 30014 publish.The ViewModel retains all UI-state composition, subscription management (via
SubscriptionManager), and ride-session lifecycle. Coordinators are unit-testable without Android context — constructor-injected via provider lambdas until Hilt migration (#52) lands.Side cleanups
stageBeforeRidemoved from a loose ViewModelvarintoDriverRideSessionso it auto-resets by construction viaresetRideUiState(), matching CLAUDE.md's "Consolidated State Resets" pattern.handlePreimageShare.Review passes
Five rounds of review landed 15 real fixes:
walletServiceProvider()calls, conditionalpublishedEventIds.clear()regression, orphaned ROADFLARE_ONLY presence event, stale KDoc, throttle log detail.CancellationExceptiongate reset inacceptBroadcastRequest,resetBroadcastGate()unconditional inhandleConfirmationTimeout,deleteAllAvailabilityEventsrevert to unconditional clear, redundantstopBroadcasting(), misplacedTODO(#52)on enum.AcceptBroadcastOutcomesealed-type split to distinguish CAS-dedup from publish failure; removed deadscope: CoroutineScopeparam onRoadflareDriverCoordinator.trackPublishedEvent→track: Booleanparam onpublishAvailability;stageBeforeRideconsolidated intoDriverRideSession; dead null checks removed.Tests
33 new unit tests in
common/src/test/java/com/ridestr/common/coordinator/:AcceptanceCoordinatorTest— CAS gate Success/DuplicateBlocked/PublishFailed outcomes,CancellationExceptiongate reset + rethrow, PaymentPath derivation (SAME_MINT / CROSS_MINT / FIAT_CASH),AcceptanceResultshape for broadcast acceptance.AvailabilityCoordinatorTest— throttle guards,publishAvailabilitytrack=true/false append semantics,deleteAllAvailabilityEventsno-op when empty + clear on success and failure,clearBroadcastStateresetting all three fields.RoadflareDriverCoordinatorMergeTest—mergeFollowerListsunion, approved logical-OR,keyVersionSentmax-with-clamp,addedAtmin, remote-newer pruning, local-newer retention;mergeMutedListsunion + never auto-unmute.All tests pass:
./gradlew :common:testDebugUnitTest :drivestr:testDebugUnitTest→BUILD SUCCESSFUL.Test plan
:common:testDebugUnitTestpasses (33 new coordinator tests + existing):drivestr:testDebugUnitTestpasses:common:compileDebugKotlinand:drivestr:compileDebugKotlincleanAcceptanceCoordinator)track = truepath)handleConfirmationTimeoutwithcurrentLocation == null— verifyresetBroadcastGate()fires so the next broadcast offer can be accepted🤖 Generated with Claude Code