Skip to content
31 changes: 27 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ When adding new information, place it in the appropriate existing section. Remov

### Working Style

- **Understand the context.** Take your time to understand how the changes _should_ fit into the complete project. Perhaps a refactor is required. Perhaps the current structure is not ideal. Take your time to indetify this.
- **Understand the context.** Take your time to understand how the changes _should_ fit into the complete project. Perhaps a refactor is required. Perhaps the current structure is not ideal. Take your time to identify this.
- **Double-check your work.** Verify changes compile and don't break existing functionality.
- **Ask clarifying questions.** When requirements are ambiguous or something is unclear or can have multiple meanings, don't assume. Ask clarifying questions where needed but try to keep these as concise and as minimal as possible.

Expand Down Expand Up @@ -127,7 +127,7 @@ case .insufficient(let shortfall):
| `@AppStorage` wrapping `UserDefaults` manually | `@AppStorage` directly | For simple per-screen preferences |
| `onChange(of:perform:)` (deprecated) | `onChange(of:initial:_:)` | Use `initial: true` when the handler should also fire on appear |

Existing `ObservableObject` classes (`Session`, `Client`, controllers) stay as-is until their dependents are migrated. A single class must use one system — either `ObservableObject` with `@Published`, or `@Observable`. Mixing causes silent observation failures.
Existing `ObservableObject` classes (`Client`, `FlipClient`) stay as-is until their dependents are migrated. A single class must use one system — either `ObservableObject` with `@Published`, or `@Observable`. Mixing causes silent observation failures.

### Generated Files

Expand Down Expand Up @@ -223,7 +223,7 @@ Container (DI)
├── FlipClient (Flipcash APIs)
├── AccountManager (Keychain)
└── SessionContainer (when logged in)
├── Session (main state, ObservableObject)
├── Session (main state, @Observable)
├── RatesController
│ ├── VerifiedProtoService (actor – caches verified exchange rate + reserve state proofs)
│ └── LiveMintDataStreamer (actor – bidirectional streaming for rates/reserves)
Expand Down Expand Up @@ -254,6 +254,20 @@ let stream = service.openMessageStream(request) { response in ... }
let stream = service.openMessageStream(request, callOptions: .streaming) { response in ... }
```

### Navigation: AppRouter

All navigation flows through `AppRouter` — a single `@Observable @MainActor` class on `SessionContainer`, injected via `@Environment(AppRouter.self)`. **Don't add screen-level `@State` sheet flags or `selectedXxx` bindings for navigation** — mutate the router instead. Deeplinks and push notifications call `router.navigate(to:)`; in-screen pushes call `router.push(_:on:)`.

Top-level sheets (`Balance`, `Settings`, `Give`) each own a `NavigationStack(path: $router[.<stack>])` and register destinations via the `.appRouterDestinations(...)` modifier on their root content. Per-stack paths are `NavigationPath` (type-erased), so sub-flow destinations (e.g., `WithdrawNavigationPath`) coexist with top-level `Destination` cases on the same stack — register `.navigationDestination(for: SubFlowPath.self)` on the sub-flow root view and push via `router.pushAny(_:on:)`. **Don't nest a `NavigationStack` inside another stack's destination** — push/pop/push corrupts SwiftUI's stack state with `comparisonTypeMismatch`.

**Local interaction sheets stay local.** Transient pickers (currency selection, buy/sell amount, funding selection) and operation-bound modals (swap/launch processing covers) belong on the screen that owns them as `.sheet(...)` / `.fullScreenCover(...)` modifiers — they're interactions or in-flight status, not navigation.

**The test:** if a deeplink could reasonably land the user here, it's a destination — route through `AppRouter`. If not, keep it local.

**Sheet path lifecycle.** `dismissSheet` leaves the dismissed sheet's `NavigationPath` populated so the closing animation runs with its current contents. The path is cleared on the next `present(_:)` of that same sheet, so re-opens land at root. Sheet swaps (presenting another sheet without dismissing first) preserve both paths for swap-back. Don't add manual `popToRoot` calls around your own dismissal — let the router handle it.

Every router mutation logs one INFO entry under `flipcash.router` — filter by that label to trace any navigation interaction.

### Key Architectural Concepts

1. **Quarks** - Smallest unit of any currency (like cents for dollars)
Expand Down Expand Up @@ -392,7 +406,7 @@ Use the project scripts — they encode the correct scheme, simulator, and desti
### Test Naming

- Use descriptive names that explain the scenario
- Format: `func testMethodName_scenario_expectedResult()` or use `@Test("description")`
- Format: `func methodName_scenario_expectedResult()` paired with `@Test("description")` for the display name

### Test the Actual Implementation

Expand Down Expand Up @@ -500,6 +514,8 @@ Types: `feat`, `fix`, `refactor`, `test`, `docs`, `chore`
| Canceling/modifying `SendCashOperation` in `dismissCashBill` | **Never** explicitly call `cancel()` or `invalidateMessageStream()` on `SendCashOperation` from `dismissCashBill`. After a grab, the received bill is a **live** `SendCashOperation` that others can scan ("quick give and grab" chain). Setting `sendOperation = nil` is fine (deinit cleans up), but explicit teardown kills a live bill. The operation's `complete()` method handles stream teardown on success/failure. |
| Using default `CallOptions` for streaming RPCs | Streaming RPCs (`openMessageStream`, `submitIntent`, `streamLiveMintData`, `statefulSwap`) must use `callOptions: .streaming`. The default 15s timeout silently kills long-lived streams. See [gRPC Call Options](#grpc-call-options). |
| Showing a received bill without `verifiedState` | Every call to `showCashBill` must pass `verifiedState` — even for `received: true` bills. The received bill creates a live `SendCashOperation` for the "quick give and grab" chain. Without `verifiedState`, launchpad currency transfers fail with "reserve state is required". Both `receiveCash` (scan) and `receiveCashLink` (deep link) must provide it. |
| Nesting a `NavigationStack` inside another stack's destination | Crashes with `SwiftUI.AnyNavigationPath.Error.comparisonTypeMismatch` on push/pop/push. Drop the inner stack; register `.navigationDestination(for: SubFlowPath.self)` on the destination's root view and push sub-flow steps via `router.pushAny(_:on:)`. The parent stack's `NavigationPath` carries both the typed `Destination` cases and the sub-flow's Hashable values. |
| Cross-stack `navigate(to:)` shows stale leaf data | When two destinations have the same case but different associated values (e.g., `.currencyInfo(A)` → `.currencyInfo(B)`), SwiftUI keeps the existing view at the same path depth and `@State` survives — the leaf renders with old data. Add `.id(value)` to the destination view in `DestinationView` so each value forces a fresh view identity. |
| `matchedGeometryEffect` applied after `.frame` | **`.matchedGeometryEffect` must come BEFORE `.frame` in the modifier chain.** Wrong order causes hero animations to fail silently: you see two separate views fading in/out at their own static positions instead of one morphing element. Paul Hudson's hackingwithswift example uses the wrong order and does not work on current iOS. Correct: `Rectangle().fill(.red).matchedGeometryEffect(id:in:).frame(width:height:)`. Incorrect: `Rectangle().fill(.red).frame(width:height:).matchedGeometryEffect(id:in:)`. Also note: `.transition(.identity)` on a parent containing matched views **kills the animation entirely** — matched geometry needs the parent view to remain in the tree briefly for interpolation, and `.identity` removes it instantly. |

---
Expand All @@ -509,6 +525,13 @@ Types: `feat`, `fix`, `refactor`, `test`, `docs`, `chore`
### Key Files

```
Navigation:
- Flipcash/Core/Navigation/AppRouter.swift (class + mutators + logging)
- Flipcash/Core/Navigation/AppRouter+Destination.swift (push targets)
- Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift (top-level sheets)
- Flipcash/Core/Navigation/AppRouter+Stack.swift (per-sheet stacks)
- Flipcash/Core/Navigation/AppRouter+DestinationView.swift (destination → view map)

Session & Auth:
- Flipcash/Core/Session/Session.swift
- Flipcash/Core/Session/SessionAuthenticator.swift
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ struct DeepLinkAction {
case .currencyInfo(let mint):
if case .loggedIn(let container) = sessionAuthenticator.state {
Analytics.deeplinkRouted(kind: kind)
container.session.pendingCurrencyInfoMint = mint
container.appRouter.navigate(to: .currencyInfo(mint))
}
}
}
Expand Down
103 changes: 103 additions & 0 deletions Flipcash/Core/Navigation/AppRouter+Destination.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
//
// AppRouter+Destination.swift
// Flipcash
//
// Created by Raul Riera on 2026-04-27.
//

import Foundation
import FlipcashCore

extension AppRouter {

/// A type-erased push target. Every screen reachable via a NavigationStack
/// push (anywhere in the app) is a case here.
enum Destination: Hashable, Sendable, CustomStringConvertible {

// Wallet flow
case currencyInfo(PublicKey)
/// Same screen as `currencyInfo` but auto-presents the funding-selection
/// sheet on appear. Modelled as a sibling case rather than an
/// associated-value flag so the trace shows "user wanted to deposit"
/// distinctly from "user opened currency info".
case currencyInfoForDeposit(PublicKey)
case discoverCurrencies
case currencyCreationSummary
case currencyCreationWizard
case transactionHistory(PublicKey)
case give(PublicKey)

// Settings flow
case settingsMyAccount
case settingsAdvancedFeatures
case settingsAppSettings
case settingsBetaFlags
case settingsAccountSelection
case settingsApplicationLogs
case accessKey
case depositCurrencyList
case deposit(PublicKey)
case withdraw

/// The stack this destination naturally belongs in. Cross-stack
/// navigation uses this to know which sheet to present.
var owningStack: Stack {
switch self {
case .currencyInfo, .currencyInfoForDeposit, .discoverCurrencies,
.currencyCreationSummary, .currencyCreationWizard,
.transactionHistory, .give:
return .balance
case .settingsMyAccount, .settingsAdvancedFeatures, .settingsAppSettings,
.settingsBetaFlags, .settingsAccountSelection, .settingsApplicationLogs,
.accessKey, .depositCurrencyList, .deposit, .withdraw:
return .settings
}
}

/// Stable, payload-free name. Used as the `destination` log key so a
/// trail can be filtered with `grep destination=currencyInfo` regardless
/// of which mint was opened. The mint itself is surfaced separately via
/// the `payload` metadata so it remains queryable but doesn't fragment
/// the destination buckets.
var description: String {
switch self {
case .currencyInfo: "currencyInfo"
case .currencyInfoForDeposit: "currencyInfoForDeposit"
case .discoverCurrencies: "discoverCurrencies"
case .currencyCreationSummary: "currencyCreationSummary"
case .currencyCreationWizard: "currencyCreationWizard"
case .transactionHistory: "transactionHistory"
case .give: "give"
case .settingsMyAccount: "settingsMyAccount"
case .settingsAdvancedFeatures: "settingsAdvancedFeatures"
case .settingsAppSettings: "settingsAppSettings"
case .settingsBetaFlags: "settingsBetaFlags"
case .settingsAccountSelection: "settingsAccountSelection"
case .settingsApplicationLogs: "settingsApplicationLogs"
case .accessKey: "accessKey"
case .depositCurrencyList: "depositCurrencyList"
case .deposit: "deposit"
case .withdraw: "withdraw"
}
}

/// Identifying associated value, if any, suitable for log metadata.
/// Returns `nil` for payload-free destinations so the log key is
/// omitted rather than serialised as an empty string.
var payload: String? {
switch self {
case .currencyInfo(let mint),
.currencyInfoForDeposit(let mint),
.transactionHistory(let mint),
.give(let mint),
.deposit(let mint):
return mint.base58
case .discoverCurrencies, .currencyCreationSummary, .currencyCreationWizard,
.settingsMyAccount, .settingsAdvancedFeatures, .settingsAppSettings,
.settingsBetaFlags, .settingsAccountSelection, .settingsApplicationLogs,
.accessKey, .depositCurrencyList, .withdraw:
return nil
}
}
}
}
Loading