Skip to content

refactor(drivestr): extract AvailabilityCoordinator, AcceptanceCoordinator, RoadflareDriverCoordinator#69

Merged
variablefate merged 6 commits intomainfrom
claude/great-lichterman-307a4b
Apr 17, 2026
Merged

refactor(drivestr): extract AvailabilityCoordinator, AcceptanceCoordinator, RoadflareDriverCoordinator#69
variablefate merged 6 commits intomainfrom
claude/great-lichterman-307a4b

Conversation

@variablefate
Copy link
Copy Markdown
Owner

@variablefate variablefate commented Apr 17, 2026

Closes #66.

Summary

Decomposes DriverViewModel into 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-local PaymentStatus enum with the shared production values in common/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, PaymentPath derivation, and broadcast first-acceptance-wins CAS gate. Returns the sealed AcceptBroadcastOutcome (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, RoadflareLocationBroadcaster lifecycle, 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

  • stageBeforeRide moved from a loose ViewModel var into DriverRideSession so it auto-resets by construction via resetRideUiState(), matching CLAUDE.md's "Consolidated State Resets" pattern.
  • Removed redundant always-true/always-false null checks flagged by the compiler in handlePreimageShare.

Review passes

Five rounds of review landed 15 real fixes:

  • Pass 1 (Sonnet): BuildConfig.DEBUG guards, double walletServiceProvider() calls, conditional publishedEventIds.clear() regression, orphaned ROADFLARE_ONLY presence event, stale KDoc, throttle log detail.
  • Pass 2 (Sonnet): CancellationException gate reset in acceptBroadcastRequest, resetBroadcastGate() unconditional in handleConfirmationTimeout, deleteAllAvailabilityEvents revert to unconditional clear, redundant stopBroadcasting(), misplaced TODO(#52) on enum.
  • Pass 3 (Sonnet): Convergence check — no new issues (investigated 6 candidates, all cleared).
  • Pass 4 (Opus): AcceptBroadcastOutcome sealed-type split to distinguish CAS-dedup from publish failure; removed dead scope: CoroutineScope param on RoadflareDriverCoordinator.
  • Pre-finalization cleanup: trackPublishedEventtrack: Boolean param on publishAvailability; stageBeforeRide consolidated into DriverRideSession; 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, CancellationException gate reset + rethrow, PaymentPath derivation (SAME_MINT / CROSS_MINT / FIAT_CASH), AcceptanceResult shape for broadcast acceptance.
  • AvailabilityCoordinatorTest — throttle guards, publishAvailability track=true/false append semantics, deleteAllAvailabilityEvents no-op when empty + clear on success and failure, clearBroadcastState resetting all three fields.
  • RoadflareDriverCoordinatorMergeTestmergeFollowerLists union, approved logical-OR, keyVersionSent max-with-clamp, addedAt min, remote-newer pruning, local-newer retention; mergeMutedLists union + never auto-unmute.

All tests pass: ./gradlew :common:testDebugUnitTest :drivestr:testDebugUnitTestBUILD SUCCESSFUL.

Test plan

  • :common:testDebugUnitTest passes (33 new coordinator tests + existing)
  • :drivestr:testDebugUnitTest passes
  • :common:compileDebugKotlin and :drivestr:compileDebugKotlin clean
  • Manual smoke: accept a broadcast offer and verify Kind 3174 publishes (AcceptanceCoordinator)
  • Manual smoke: ROADFLARE_ONLY mode toggle → go offline → verify presence event is NIP-09 deleted (track = true path)
  • Manual smoke: handleConfirmationTimeout with currentLocation == null — verify resetBroadcastGate() fires so the next broadcast offer can be accepted

🤖 Generated with Claude Code

variablefate and others added 2 commits April 17, 2026 08:44
…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>
@variablefate variablefate marked this pull request as ready for review April 17, 2026 15:50
@variablefate
Copy link
Copy Markdown
Owner Author

Code review

Found 1 issue:

  1. RoadflareDriverCoordinator drops BuildConfig.DEBUG guards that existed in the original DriverViewModel code, causing two verbose log lines to run unconditionally in release builds. The common/ module has its own BuildConfig (used in RoadflareLocationBroadcaster, WalletService, etc.), so this is fixable. The two affected lines:
    • Line 135: Log.w(TAG, "Kind 30011 query timed out …") — was if (BuildConfig.DEBUG) gated
    • Line 343: Log.d(TAG, "Remote newer; pruning … stale local-only followers") — was if (BuildConfig.DEBUG) gated

val queryResult = nostrService.queryCurrentFollowerPubkeys(driverPubKey)
if (!queryResult.success) {
Log.w(TAG, "Kind 30011 query timed out — using merged followers as fallback")
mergedFollowers
} else {

val localOnlyPubkeys = local.map { it.pubkey }.filter { it !in remotePubkeys }
if (localOnlyPubkeys.isNotEmpty()) {
Log.d(TAG, "Remote newer; pruning ${localOnlyPubkeys.size} local-only stale followers")
localOnlyPubkeys.forEach { byPubkey.remove(it) }
}

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

variablefate and others added 2 commits April 17, 2026 09:33
- 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>
@variablefate
Copy link
Copy Markdown
Owner Author

Code review — Second pass

Found 5 issues. First pass already fixed 6; these are new findings from the improved state.

Advanced (verified real, fixed in commit 5be8340):

  1. acceptBroadcastRequest CAS gate not reset on coroutine cancellation — if the coroutine is cancelled (e.g. during logout) while suspended inside acceptBroadcastRide, CancellationException propagates without resetting hasAcceptedBroadcast, leaving it permanently true until the next resumeOfferSubscriptions(). Added catch (CancellationException) reset + rethrow.

val eventId = try {
nostrService.acceptBroadcastRide(
request = request,
walletPubKey = walletPubKey,
mintUrl = driverMintUrl,
paymentMethod = request.paymentMethod
)
} catch (e: kotlinx.coroutines.CancellationException) {
hasAcceptedBroadcast.set(false)
throw e
} ?: run {
Log.e(TAG, "acceptBroadcastRide returned null — Nostr publish failed")
hasAcceptedBroadcast.set(false) // allow retry

  1. handleConfirmationTimeout() skips resetBroadcastGate() when currentLocation is null — the gate was only reset inside if (location != null) which calls resumeOfferSubscriptions. A timeout with no location left hasAcceptedBroadcast = true, silently blocking all future broadcast acceptances until the next proceedGoOnline(). Fixed by calling acceptanceCoordinator.resetBroadcastGate() unconditionally before the location check.

clearSavedRideState()
// Always reset broadcast gate so the next broadcast offer can be accepted,
// even if location is null and we skip resumeOfferSubscriptions() below.
acceptanceCoordinator.resetBroadcastGate()
// Resume broadcasting
val location = _uiState.value.currentLocation
if (location != null) {
availabilityCoordinator.clearBroadcastState()
resumeOfferSubscriptions(location)
Log.d(TAG, "Resumed broadcasting after confirmation timeout")
}

  1. deleteAllAvailabilityEvents() "retain for retry" was immediately defeated — prior fix commit (06d4951) made it retain IDs on failure, but clearBroadcastState() (called on the very next line in every call site) always cleared them anyway. No retry mechanism exists. Reverted to unconditional clear with an honest KDoc.

* Suspends until the deletion event is confirmed sent (or fails). Event IDs are
* always cleared after this call — stale events expire naturally if the relay
* request fails, so no retry is warranted.
*/
suspend fun deleteAllAvailabilityEvents() {
if (publishedEventIds.isEmpty()) {
Log.d(TAG, "deleteAllAvailabilityEvents: nothing to delete")
return
}
Log.d(TAG, "Deleting ${publishedEventIds.size} availability events")
val deletionId = nostrService.deleteEvents(
publishedEventIds.toList(),
"driver went offline",
listOf(RideshareEventKinds.DRIVER_AVAILABILITY)
)
if (deletionId != null) {
Log.d(TAG, "Deletion request sent: $deletionId")
} else {
Log.w(TAG, "Deletion request failed — events will expire naturally")
}
publishedEventIds.clear()
}
// -------------------------------------------------------------------------

  1. Redundant stopBroadcasting() before startBroadcasting() in handleLocationUpdate()startBroadcasting() KDoc says it cancels the running job internally; the explicit stop set broadcastJob = null first, making the internal cancel a no-op and contradicting the documented interface.

availabilityCoordinator.updateThrottle(newLocation)
// Restart broadcasting with the new location to trigger immediate update.
// startBroadcasting() cancels the running job internally (see its KDoc).
startBroadcasting(newLocation)

  1. Misplaced TODO(#52) on PaymentStatus enum — enums are never Hilt-injected; the comment had no valid target and was copied from the coordinator classes in error.

* This is not a lifecycle enum — it reflects the driver's *readiness to claim*, not the
* overall payment lifecycle (see [EscrowType] / [EscrowDetails] for lifecycle state).
*/
enum class PaymentStatus {
/** Non-HTLC payment path (fiat cash, cross-mint bridge already complete, or no payment). */
NO_PAYMENT_EXPECTED,

Verified false positive / pre-existing (not actioned):

  • confirmationInFlight AtomicBoolean cited as missing — driver-side does not use this; it is RiderViewModel-only. hasAcceptedBroadcast in AcceptanceCoordinator is the correct driver-side equivalent.
  • publishedEventIds thread safety — all access routes through viewModelScope (Dispatchers.Main), single-threaded; no actual data race.
  • _uiState.value = vs .update {} — pre-existing throughout ViewModel, not introduced by this PR.
  • BuildConfig.DEBUG module mismatch — com.ridestr.common.BuildConfig.DEBUG and drivestr.BuildConfig.DEBUG are equivalent in practice; same build variant sets both.
  • onCleared() missing stopBroadcasting()performLogoutCleanup() already calls it at line 3821.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@variablefate
Copy link
Copy Markdown
Owner Author

Code review — Third pass

No new findings worth acting on. The extraction is clean.

Checked at HEAD 5be83405f10a88ac23e6251ac206da2093c43c44:

Investigated and cleared:

  • ensureStateSynced() returning verifiedFollowerPubkeys = null when stateChanged == false — correct behaviour: when merged state is identical to local state the follower list is already verified, no refresh signal is needed.
  • cancelCurrentRide deferring resetRideUiState/clearSavedRideState inside a coroutine — intentional: cancellation event must be sent before the UI resets. Synchronous block still covers closeAllRideSubscriptionsAndJobs, clearDriverStateHistory, stageBeforeRide = null, and service stop. Pre-existing, not introduced by this PR.
  • cancelCurrentRide missing resetBroadcastGate()cancelCurrentRide goes to DriverStage.OFFLINE; gate is reset via resumeOfferSubscriptions() on the next goOnline(). No window where a stuck gate can block offers.
  • trackPublishedEvent() + broadcast loop single-delete pattern — Kind 30173 is a parameterised replaceable event; relay-side replacement handles any duplicates. All tracked IDs are batch-deleted by deleteAllAvailabilityEvents() on go-offline.
  • publishedEventIds thread safety — all access routes through viewModelScope (Dispatchers.Main), single-threaded dispatcher, no actual data race.
  • stageBeforeRide and completeRideInternal patterns — pre-existing, not introduced by the coordinator extraction.
  • RoadflareLocationBroadcaster internal IO scope on destroy()onCleared() calls performLogoutCleanup() which calls roadflareCoordinator.destroy() which calls broadcaster?.destroy() which cancels the internal scope. Path is complete.

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>
@variablefate
Copy link
Copy Markdown
Owner Author

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

  1. CAS-gate-blocked broadcast acceptance surfaced as a user-visible error. AcceptanceCoordinator.acceptBroadcastRequest returned AcceptanceResult?, with null meaning either "CAS gate blocked a duplicate (silent — another caller is handling it)" or "Nostr publish failed (surface to user)". The caller treated both the same and showed "Failed to accept ride request" on multi-relay duplicate deliveries or rapid taps. Split into AcceptBroadcastOutcome sealed type (Success / DuplicateBlocked / PublishFailed); caller handles each appropriately.

/**
* Outcome of [AcceptanceCoordinator.acceptBroadcastRequest].
*
* Distinguishes CAS-gate deduplication (silent — another caller is handling it) from
* a real Nostr publish failure (surface to user). Without this split, a rapid duplicate
* invocation would look identical to a failure at the call site.
*/
sealed class AcceptBroadcastOutcome {
data class Success(val result: AcceptanceResult) : AcceptBroadcastOutcome()
object DuplicateBlocked : AcceptBroadcastOutcome()
object PublishFailed : AcceptBroadcastOutcome()
}

is AcceptBroadcastOutcome.Success -> {
val result = outcome.result
setupAcceptedRide(
acceptanceEventId = result.acceptanceEventId,
offer = result.offer,
broadcastRequest = result.broadcastRequest,
walletPubKey = result.walletPubKey,
driverMintUrl = result.driverMintUrl,
paymentPath = result.paymentPath,
cleanupTag = "BROADCAST_ACCEPTANCE"
)
}
AcceptBroadcastOutcome.DuplicateBlocked -> {
// Another invocation is handling this broadcast; stay silent.
Log.d(TAG, "Broadcast acceptance deduplicated by CAS gate")
}
AcceptBroadcastOutcome.PublishFailed -> {
_uiState.update { current ->
current.copy(
error = "Failed to accept ride request",
rideSession = current.rideSession.copy(isProcessingOffer = false)
)
}
}
}
}
}

  1. Dead scope: CoroutineScope constructor parameter in RoadflareDriverCoordinator. Field was stored but never referenced anywhere in the class — the coordinator's work is done in suspend functions and the wrapped RoadflareLocationBroadcaster owns its own internal scope. Removed parameter and unused imports; updated ViewModel instantiation.

private val driverRoadflareRepository: DriverRoadflareRepository
) {
companion object {
private const val TAG = "RoadflareDriverCoord"

Verified false positive / pre-existing (not actioned):

  • deleteAllAvailabilityEvents unconditional clear — pass 2 intentionally reverted conditional-clear after confirming no retry mechanism exists; not a regression to re-revert.
  • In-loop deleteEvent missing NIP-09 k-tag — verified pre-existing in DriverViewModel before the refactor; batch deletion at go-offline self-heals.
  • CancellationException coverage in acceptBroadcastRequest — verified that all code after the publish suspend is synchronous, so cancellation cannot land between publish success and return.
  • Dropped log verbosity in broadcast loop and throttle — cosmetic observability change, not a correctness issue.
  • ensureStateSynced retry during delay(1_000) cancel — state unchanged before delay; clean cancellation.

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>
@variablefate variablefate merged commit f52e31a into main Apr 17, 2026
@variablefate variablefate deleted the claude/great-lichterman-307a4b branch April 17, 2026 21:13
variablefate added a commit that referenced this pull request Apr 18, 2026
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>
variablefate added a commit that referenced this pull request Apr 19, 2026
* 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>
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.

Decompose DriverViewModel into shared domain coordinators

1 participant