diff --git a/CLAUDE.md b/CLAUDE.md index 5bb885c21e..d9b1a30ed3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,6 +6,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Hedvig Android app - A modern Android application built with Jetpack Compose, Apollo GraphQL, and Kotlin. The app uses a highly modular architecture with 80+ modules organized into feature, data, and core layers. +Two foundations are newer than most of the codebase and are easy to get wrong if you assume the old patterns: + +- **Dependency injection is Metro** (`dev.zacsweers.metro`), a compile-time DI framework. **Koin is gone.** If you find yourself writing a `module { }` or calling `get()`, stop — you are following a stale pattern. +- **Navigation is Navigation 3** (`androidx.navigation3`) on top of a single app-owned back stack. **There is no `NavController`, no `NavHost`, no route strings, no `navgraph`/`navdestination`.** Destinations are `@Serializable` keys; the back stack is a plain mutable list of keys. + +A full narrative of *why* these look the way they do — the engineering decisions, the alternatives rejected, the invariants — lives in `docs/architecture/navigation-and-di.md`. Read it before making structural changes to navigation or DI. This file is the day-to-day quick reference. + ## Essential Setup Commands ### Initial Setup @@ -64,30 +71,32 @@ Hedvig Android app - A modern Android application built with Jetpack Compose, Ap The codebase is organized under `/app` with 80+ modules following a strict modularization pattern: -- **app/** - Main application module +- **app/** - Main application module. Owns the single Metro `AppGraph`, the single `NavDisplay`, the back stack controller, and all cross-feature navigation wiring. - **feature/** - Feature modules (feature-home, feature-chat, feature-login, etc.) +- **feature/feature-{name}-navigation/** - Tiny modules holding *only* the public `@Serializable HedvigNavKey`s of a feature that other features may navigate to. These are the one carve-out to the "features can't depend on features" rule. - **data/** - Data layer modules (data-contract, data-chat, data-addons, etc.) - **core/** - Core utilities (core-common, core-datastore, core-resources, etc.) - **apollo/** - GraphQL client modules (apollo-octopus-public, apollo-core, etc.) -- **navigation/* - Navigation modules (navigation-compose, navigation-core, etc.) -- **design-system/* - Design system components +- **navigation/** - Navigation infrastructure: `navigation-common` (KMP, holds `HedvigNavKey` + marker interfaces), `navigation-compose` (KMP, the `Backstack` interface, deep-link matching, decorators), `navigation-keys-processor` (KSP processor generating serializer registrations), `navigation-activity` (Android `ExternalNavigator`). +- **design-system/** - Design system components - **ui/** - Shared UI components - **auth/** - Authentication modules - **database/** - Room database modules - **language/** - Localization modules +- **shareddi/** - KMP module declaring the iOS `IosGraph` (Metro graph for the iOS target). - **Other utilities** - payment, tracking, logging, featureflags, etc. -**Critical architectural rule:** Feature modules CANNOT depend on other feature modules. This is enforced at build time by the `hedvig.gradle.plugin`. +**Critical architectural rule:** Feature modules CANNOT depend on other feature modules. This is enforced at build time by `hedvig.gradle.plugin` (`configureFeatureModuleGuidelines()`). The single exception: any module can depend on a `feature-{name}-navigation` module, because those exist precisely to be shared cross-feature. ### Module Naming Conventions - `{name}-public` - Public APIs and interfaces (often KMP-compatible) - `{name}-android` - Android-specific implementations - `{name}-test` - Test utilities +- `feature-{name}-navigation` - Public navigation keys of a feature (cross-feature depend-able) - No suffix for main implementation modules -However, if a module is KMP compatible, there is no need for the `-public` or `-android` suffix. -The android-specific code lives inside the `androidMain` directory instead. (see `:language-core`) +If a module is KMP compatible, there is no need for the `-public` or `-android` suffix. The android-specific code lives inside the `androidMain` directory instead (see `:language-core`). ### Build Types @@ -99,21 +108,21 @@ The android-specific code lives inside the `androidMain` directory instead. (see ### MVI with Molecule -The app uses Molecule (Cash App's library) for reactive state management: +The app uses Molecule (Cash App's library) for reactive state management. This is unchanged by the Metro/Nav3 migration: ```kotlin // ViewModels delegate to Presenters class FeatureViewModel( - useCaseProvider: Provider, + useCase: FeatureUseCase, ) : MoleculeViewModel( - FeatureUiState.Loading, - FeaturePresenter(/* ... */), + initialState = FeatureUiState.Loading, + presenter = FeaturePresenter(useCase), ) // Presenters contain presentation logic class FeaturePresenter : MoleculePresenter { @Composable - override fun present(events: Flow): FeatureUiState { + override fun MoleculePresenterScope.present(lastState: FeatureUiState): FeatureUiState { // Composable state management logic } } @@ -121,80 +130,138 @@ class FeaturePresenter : MoleculePresenter { **Flow:** User Action → Event → Presenter → UiState → UI -### Feature Module Pattern +### Dependency Injection (Metro) -Each feature module follows this structure: +Metro is a **compile-time** DI framework. There is a single graph, `AppScope`, for the whole app — no subscoping. Bindings are *contributed* from any module and merged into that one graph at compile time. -``` -feature-{name}/ -├── build.gradle.kts -└── src/main/kotlin/com/hedvig/android/feature/{name}/ - ├── ui/ - │ ├── {Name}Destination.kt # Composable entry point - │ ├── {Name}ViewModel.kt # MoleculeViewModel - │ ├── {Name}Presenter.kt # MoleculePresenter - │ └── {Name}Layout.kt # UI components - ├── navigation/ - │ └── {Name}Graph.kt # Navigation setup - └── di/ - └── {Name}Module.kt # Koin DI module -``` +**Core annotations you will actually use:** -### Navigation +- `@Inject` — constructor (or, on `MainActivity`/Application/Service, field) injection. +- `@SingleIn(AppScope::class)` — a singleton within the app graph. Apply to anything that must have exactly one instance (stateful services, caches, the back stack controller). Put it *wherever the binding is declared*: on the `@Inject` constructor (e.g. `SessionReconciler`), or on the `@Provides` method when you can't annotate the constructor (e.g. `BackstackController`, whose constructor takes hand-built snapshot holders — it's provided via `BackstackControllerProviders`). +- `@ContributesBinding(AppScope::class)` — on an implementation class, binds it to its interface in the graph. The standard way to provide an `Impl` for an interface. +- `@Provides` inside a `@ContributesTo(AppScope::class) interface` — for bindings you can't annotate a constructor on (framework types, builders, things needing configuration). See `ApplicationMetroProviders`. +- `@ContributesIntoSet` / `@ContributesIntoMap` — multibindings. Used for sets of `SerializersModule`, deep-link matcher providers, notification senders, and the ViewModel/worker maps. +- `@Multibinds(allowEmpty = true)` — declares a multibound collection on the graph even when no module contributes to it. +- `@AssistedInject` + `@AssistedFactory` — runtime parameters (e.g. a screen's `contractId`) combined with injected dependencies. -Uses type-safe Navigation Compose with custom extensions: +**The app graph** is declared once, in `:app`: ```kotlin -// Destinations are serializable sealed interfaces -sealed interface FeatureDestination : Destination { - @Serializable - data object Graph : FeatureDestination - - @Serializable - data class Detail(val id: String) : FeatureDestination +@DependencyGraph(AppScope::class) +internal interface AppGraph : ViewModelGraph { + val workerFactory: MetroWorkerFactory + @Multibinds(allowEmpty = true) + val serializersModules: Set + fun inject(activity: MainActivity) + fun inject(application: HedvigApplication) + @DependencyGraph.Factory + interface Factory { fun create(@Provides applicationContext: Context): AppGraph } } +``` + +**ViewModels are resolved through Metro, not `viewModel()`:** Inside an `entry { }` block use: + +```kotlin +// No runtime args: +val vm: InsuranceViewModel = metroViewModel() + +// With assisted (navigation) args: +val vm: ContractDetailViewModel = + assistedMetroViewModel { + create(key.contractId) + } +``` -// Navigation graphs -fun NavGraphBuilder.featureGraph( - navigator: Navigator, -) { - navgraph { - navdestination { backstackEntry -> - // Composable UI - } +To register a ViewModel, contribute its factory into the graph map: + +```kotlin +@AssistedInject +internal class ContractDetailViewModel( + @Assisted contractId: String, + useCase: GetContractForContractIdUseCase, +) : MoleculeViewModel<...>(...) { + @AssistedFactory + @ManualViewModelAssistedFactoryKey + @ContributesIntoMap(AppScope::class) + fun interface Factory : ManualViewModelAssistedFactory { + fun create(@Assisted contractId: String): ContractDetailViewModel } } ``` -**Top-level navigation graphs:** Home, Insurances, Forever, Payments, Profile +A no-arg ViewModel uses `@Inject` + `@ContributesIntoMap(AppScope::class)` + `@ViewModelKey(MyViewModel::class)` instead. The central `AppViewModelFactory` (a `MetroViewModelFactory`) is provided into the composition via `LocalMetroViewModelFactory` in `MainActivity`. -### Dependency Injection +**Demo mode** is the one place we need two implementations of the same type. Use the `Provider` fun interface and a `ProdOrDemoProvider` (always `@SingleIn(AppScope::class)`), which picks `demoImpl` vs `prodImpl` off `DemoManager`. Inject `Provider` and call `.provide()`. Do **not** reach for `Provider` for anything else. + +**WorkManager** workers are built through `MetroWorkerFactory`, a multibound `Map, ChildWorkerFactory>`. A worker contributes an `@AssistedFactory` `ChildWorkerFactory` keyed with `@WorkerKey`. + +**Required Gradle flag:** `metro.generateContributionProviders=true` in `gradle.properties`. The Metro compiler plugin is auto-applied to every module by `hedvig.gradle.plugin` (`configureMetro`), so module `build.gradle.kts` files never apply it manually. + +### Navigation (Navigation 3) + +There is **one** `NavDisplay`, in `HedvigApp`, rendering a **single back stack** that is a `SnapshotStateList`. There is no `NavController` and no route strings. -Uses Koin with modular configuration: +**Destinations are keys.** A destination is a `@Serializable` class/object implementing `HedvigNavKey`: ```kotlin -// Each module has its own DI module -val featureModule = module { - viewModel { FeatureViewModel(get()) } - single { FeatureUseCase(get(), get()) } +@Serializable +data object InsurancesKey : HedvigNavKey, CrossSellEligibleDestination, TopLevelTabRoot { + override val topLevelTab = TopLevelTab.Insurances } -// All modules are included in ApplicationModule -val applicationModule = module { - includes( - featureModule, - dataModule, - networkModule, - // ... 40+ modules - ) +@Serializable +internal data class InsuranceContractDetailKey(val contractId: String) : HedvigNavKey, DeepLinkAncestry, CrossSellEligibleDestination { + override val owningTab = TopLevelTab.Insurances + override val syntheticParents = emptyList() } ``` -**Patterns:** -- Use `Provider` when we need a different implementation for the demo mode of the App, which we very rarely do. We always do that using `ProdOrDemoProvider` -- Each feature/data module has its own DI module -- Common dependencies (logging, tracking) auto-injected by build plugin -- When a Presenter or ViewModel needs to call a use case, always inject the use case directly as a typed dependency — never abstract it into an anonymous `suspend () -> T` lambda. If two separate operations are needed (e.g. payin vs payout setup), create two separate, dedicated use case classes and two separate presenters. Do not create a shared interface just to enable reuse through a single presenter. +Keys reachable cross-feature live in the feature's `-navigation` module and are public. Keys internal to a feature stay `internal` in the feature module. + +**Marker interfaces** (in `navigation-common`) let `:app` reason about a key without depending on the feature: +- `TopLevelTabRoot` — this key is the root of a bottom-nav tab (exposes `topLevelTab`). +- `DeepLinkAncestry` — how to build a synthetic back stack when this key is entered alone (exposes `owningTab` + `syntheticParents`). +- `CrossSellEligibleDestination` — the cross-sell sheet may appear here. +- `SuppressesChatPushNotification` — suppress chat push while this screen is shown. +- `DeliberateLogoutOrigin` — reaching logout from here is intentional; don't stash the session for restore. + +**The back stack API.** Presenters and entries receive the `Backstack` interface (the `:app` `BackstackController` is bound to it). `entries` is the source of truth; helpers are extensions: + +```kotlin +backstack.add(ChatKey(id)) // push +backstack.popBackstack() // pop one; at the root it finishes the app (Back/close exits) +backstack.popUpTo(inclusive = true) +backstack.navigateAndPopUpTo(BarKey, inclusive = true) +backstack.navigateUp() // task-aware up (deep links) +backstack.removeAllOf() +``` + +Never hold a long-lived reference to `entries` snapshot contents; mutate through the controller/extensions so changes are observed and persisted. + +**Registering destinations.** Each feature exposes a `fun EntryProviderScope.featureEntries(...)` that calls `entry { }` for each of its screens. `:app` calls all of them from `hedvigEntryProvider`. Cross-feature navigation is done by `:app` passing `navigateToX` lambdas into each feature's entries function — features never import each other's keys. + +```kotlin +fun EntryProviderScope.insuranceEntries(backstack: Backstack, /* navigateToX lambdas */) { + entry(metadata = NavSuiteSceneDecoratorStrategy.showNavBar()) { + val vm: InsuranceViewModel = metroViewModel() + InsuranceDestination(viewModel = vm, /* ... */) + } +} +``` + +**Process-death survival is automatic but requires opt-in per module.** Add `navKeys()` to the module's `hedvig { }` block. The `navigation-keys-processor` KSP processor finds every concrete `@Serializable HedvigNavKey` in the module and generates a Metro `@ContributesIntoSet SerializersModule` provider registering them polymorphically. `:app` merges all contributed modules and uses them to (de)serialize the back stack into the Activity's `SavedStateRegistry`. **If you add a key but forget `navKeys()`, the app will crash on restore** with a missing polymorphic serializer. + +**Multiple back stacks (tabs)** are handled by the "runs model" in `BackstackController`/`TopLevelRunLogic`: Home's run is always at the base of `entries`; side tabs are parked in `parkedRuns` when you switch away and restored when you switch back. Tab state (saveable state + ViewModels) of parked runs is kept alive by the retained `NavEntryDecorator`s, which consult `allLiveContentKeys`. + +**Where the heavy logic lives** (read these, don't reinvent): +- `BackstackController.kt` — app-scoped singleton, owns all nav state, tab switching, login/logout stash, deep-link routing, task-aware Up. +- `NavigationStateBridge.kt` — the single seam between Activity lifecycle and the controller (seed/restore/persist + escape-to-own-task handoff). +- `SessionReconciler.kt` — auth↔back-stack reconciliation; gates the splash via `isReady`; forced logout. +- `HedvigEntryProvider.kt` — all destination registration and cross-feature lambda wiring. + +### Deep Links + +Each feature builds `DeepLinkMatcher`s from its `HedvigDeepLinkContainer` patterns and contributes a `DeepLinkMatcherProvider` (`@ContributesIntoSet`). `:app` aggregates them into one `HedvigDeepLinkMatcher`. `MainActivity` forwards `ACTION_VIEW` intents as raw URI strings down a `deepLinkChannel`; `HedvigApp` matches each to a key and routes it through the controller once logged in. A `DeepLinkAncestry` key entered while logged out is held as `pendingDeepLink` and landed after login. ### Data Layer @@ -203,23 +270,23 @@ Data modules follow this structure when they are not KMP compatible: ``` data-{domain}/ ├── data-{domain}-public/ # Interfaces/models -│ └── src/main/ └── data-{domain}-android/ # Android implementation (optional) ``` -And they follow this structure when they are KMP compatible: +And this structure when they are KMP compatible: ``` data-{domain}/ -└── data-{domain}/ # Interfaces/models (KMP) - └── src/commonMain/ +└── data-{domain}/ # Interfaces/models (KMP), androidMain for android-specific code ``` **Patterns:** -- Repository pattern with interfaces -- Apollo GraphQL queries/mutations -- Use cases for business logic -- Room database for local persistence +- Repository pattern with interfaces, bound via `@ContributesBinding(AppScope::class)`. +- Apollo GraphQL queries/mutations. +- Use cases for business logic, injected directly as typed dependencies. +- Room database for local persistence. + +When a Presenter or ViewModel needs a use case, inject it directly as a typed dependency — never abstract it into an anonymous `suspend () -> T` lambda. If two separate operations are needed (e.g. payin vs payout setup), create two separate, dedicated use case classes and two separate presenters. Do not create a shared interface just to enable reuse through a single presenter. **Critical architectural rule — never expose GraphQL types in public API:** @@ -230,7 +297,7 @@ Use cases and repositories should: 2. Map the response into a project-owned type (a plain Kotlin `data class`, sealed type, primitive, or `Unit` if only success/failure matters) before returning. 3. Keep the `octopus.*` import confined to the `internal` impl class only. -This applies even when the GraphQL type happens to be a perfect shape — wrap it. It keeps the rest of the project insulated from schema churn, makes the data source swappable, and prevents GraphQL types from leaking into KMP/iOS-facing APIs where they'd be even more awkward. +This applies even when the GraphQL type happens to be a perfect shape — wrap it. It keeps the rest of the project insulated from schema churn, makes the data source swappable, and prevents GraphQL types from leaking into KMP/iOS-facing APIs. Example — wrong: ```kotlin @@ -256,12 +323,11 @@ internal class SetArticleRatingUseCaseImpl(...) : SetArticleRatingUseCase { } ``` -When the response carries useful structured data, define a project-owned `data class` next to the use case (or in a shared model file) and map field-by-field in the impl. - ## Technology Stack ### UI - **Jetpack Compose** - 100% Compose, no XML layouts +- **Navigation 3** (`androidx.navigation3`) - single `NavDisplay` over an app-owned back stack - **Material 3** - Window size classes, theming. Only used internally by our design-system-internals - **Coil** - Image loading (SVG, GIF, PDF support) - **ExoPlayer** (Media3) - Video playback @@ -278,11 +344,14 @@ When the response carries useful structured data, define a project-owned `data c - **Kotlin Coroutines** - Asynchronous programming - **Kotlin Flow** - Reactive streams - **Molecule** - Reactive state management -- **Arrow** - Functional programming utilities (Either, etc.) +- **Arrow** - Functional programming utilities (Either, raceN, etc.) + +### Dependency Injection +- **Metro** (`dev.zacsweers.metro`) - compile-time DI, single `AppScope` graph +- **metro-viewmodel / metro-viewmodel-compose** - ViewModel resolution on Android ### Other -- **Koin** - Dependency injection (with BOMs) -- **kotlinx.serialization** - JSON serialization +- **kotlinx.serialization** - JSON serialization, polymorphic back-stack persistence - **Timber** - Logging - **Datadog** - Analytics and RUM - **Firebase** - Crashlytics, Analytics, Messaging @@ -295,16 +364,17 @@ When the response carries useful structured data, define a project-owned `data c The project uses custom Gradle convention plugins for consistent configuration: - **hedvig.gradle.plugin** - Base plugin with: - - Feature module dependency enforcement (features can't depend on features) + - Feature module dependency enforcement (features can't depend on features, except `-navigation` modules) + - Auto-application of the **Metro** compiler plugin to every Kotlin module + - Auto-addition of metro-viewmodel deps to Android modules - Ktlint configuration - - Common dependencies (Koin BOM, Compose BOM, OkHttp BOM) - - Auto-injection of logging and tracking + - Common dependencies (Compose BOM, logging, tracking auto-injected) - **hedvig.android.application** - Android app configuration - **hedvig.android.library** - Android library configuration - **hedvig.jvm.library** - Pure Kotlin (JVM) libraries - **hedvig.multiplatform.library** - KMP support -- **hedvig.multiplatform.library.android** - used in conjuction with hedvig.multiplatform.library to add an android target to that module when it needs to have android-specific code which can not be just jvm code instead +- **hedvig.multiplatform.library.android** - adds an android target to a KMP module when it needs android-specific code ### HedvigGradlePluginExtension DSL @@ -317,15 +387,15 @@ plugins { } hedvig { - apollo("octopus") // Enable Apollo codegen - compose() // Enable Jetpack Compose - serialization() // Enable kotlinx.serialization - androidResources() // Enable Android resources - room(false) { /* config */ } // Enable Room database + apollo("octopus") // Enable Apollo codegen with the given generated package + compose() // Enable Jetpack Compose + serialization() // Enable kotlinx.serialization + androidResources() // Enable Android resources + room(false) { ... } // Enable Room database + navKeys() // Wire the nav-keys KSP processor (REQUIRED if the module declares HedvigNavKeys) } dependencies { - // Use type-safe project accessors implementation(projects.coreCommonPublic) implementation(projects.navigationCompose) implementation(projects.designSystemHedvig) @@ -352,7 +422,8 @@ Configuration in `.editorconfig`: - **Regular functions:** camelCase - **ViewModels:** `{Feature}ViewModel` - **Presenters:** `{Feature}Presenter` -- **Destinations:** `{Feature}Destination` +- **Destinations / nav keys:** `{Feature}Key` (e.g. `InsurancesKey`, `ChatKey`) +- **Entry functions:** `{feature}Entries` - **Use cases:** `{Action}{Domain}UseCase` (e.g., `GetHomeDataUseCase`) ## Working with GraphQL @@ -373,20 +444,7 @@ Configuration in `.editorconfig`: ### Writing GraphQL Queries -Place `.graphql` files in module's `src/main/graphql/`: - -```graphql -# GetHomeData.graphql -query GetHomeData { - currentMember { - id - firstName - lastName - } -} -``` - -Apollo generates type-safe Kotlin code automatically. +Place `.graphql` files in module's `src/main/graphql/`. Apollo generates type-safe Kotlin code automatically. Keep generated `octopus.*` types confined to internal impl classes (see the data layer rule above). ## Testing @@ -400,16 +458,14 @@ Apollo generates type-safe Kotlin code automatically. # Run unit tests only ./gradlew testDebugUnitTest - -# Run with coverage (if configured) -./gradlew testDebugUnitTestCoverage ``` **Test patterns:** -- Unit tests: `src/test/kotlin/` +- Unit tests: `src/test/kotlin/` (or `src/commonTest/` for KMP) - Android tests: `src/androidTest/kotlin/` - Use Turbine for testing Flows - Use test modules for shared test utilities +- Navigation invariants are covered by `ExhaustiveBackStackSerializationTest` (every `HedvigNavKey` round-trips through serialization) and `BackstackTest`. If you add a key, these guard process-death survival. ## CI/CD @@ -424,9 +480,13 @@ GitHub Actions workflows (in `.github/workflows/`): ## Important Files -- **build-logic/convention/** - Gradle convention plugins +- **build-logic/convention/** - Gradle convention plugins (Metro wiring, feature isolation, the `hedvig {}` DSL) +- **app/app/.../di/AppGraph.kt** - the single Metro graph +- **app/app/.../navigation/BackstackController.kt** - the single source of navigation truth +- **app/navigation/** - navigation infrastructure + KSP processor +- **docs/architecture/navigation-and-di.md** - the deep design spec for navigation + DI - **settings.gradle.kts** - Module discovery and configuration -- **gradle.properties** - Project properties +- **gradle.properties** - Project properties (`metro.generateContributionProviders=true` lives here) - **.editorconfig** - Code style configuration ## Common Tasks @@ -443,6 +503,7 @@ plugins { hedvig { compose() + navKeys() // if the module declares any HedvigNavKey apollo("octopus") // if needed } @@ -452,31 +513,31 @@ dependencies { implementation(projects.designSystemHedvig) } ``` -3. Create standard structure: `ui/`, `navigation/`, `di/` -4. Module will be auto-discovered by `settings.gradle.kts` +3. Create standard structure: `ui/`, `navigation/`, `di/` (contributions live next to the classes they bind via Metro annotations, not in a central `di` module). +4. Define `@Serializable` `HedvigNavKey`s. Put any cross-feature-reachable keys in a `feature-{name}-navigation` module. +5. Expose a `fun EntryProviderScope.{name}Entries(...)` and call it from `HedvigEntryProvider` in `:app`. +6. Module will be auto-discovered by `settings.gradle.kts`. -### Adding a New Data Module +### Adding a New Screen/Destination -1. Create `-public` module for interfaces (KMP-compatible) -2. Create `-android` module for implementations (if needed) -3. Add Koin module in `di/` -4. Use Repository pattern for data access +1. Define `@Serializable {Name}Key : HedvigNavKey` (add marker interfaces as needed). +2. Register it: `entry<{Name}Key> { key -> ... }` in the feature's entries function. +3. Resolve the ViewModel with `metroViewModel()` / `assistedMetroViewModel(...)`. +4. Ensure the module has `navKeys()` so the key survives process death. +5. For cross-feature entry, thread a `navigateToX` lambda from `:app` rather than importing the key. ### Adding a New GraphQL Query -1. Create `.graphql` file in `src/main/graphql/` -2. Enable Apollo in `build.gradle.kts`: `hedvig { apollo("octopus") }` -3. Build generates type-safe Kotlin code -4. Use generated query class in repository/use case +1. Create `.graphql` file in `src/main/graphql/`. +2. Enable Apollo in `build.gradle.kts`: `hedvig { apollo("octopus") }`. +3. Build generates type-safe Kotlin code. +4. Use the generated query in an internal repository/use case impl; return a project-owned type. ### Working with Translations ```bash # Download latest translations ./gradlew downloadStrings - -# Translations are managed via Lokalise -# String resources in app/core/core-resources/ ``` **IMPORTANT:** String resource XML files (`strings.xml`) are fully managed by Lokalise and regenerated on every `./gradlew downloadStrings` run. **Never add new strings directly to any `strings.xml` file** — they will be overwritten and lost. @@ -505,13 +566,19 @@ Text("This is some text for feature X") ./gradlew downloadStrings ``` +**App crashes on process-death restore / "polymorphic serializer not found":** +- The module declaring the key is missing `navKeys()` in its `hedvig {}` block, or the key isn't `@Serializable`. + +**Metro "cannot find binding" / duplicate binding errors:** +- Check the type is contributed (`@ContributesBinding`/`@Provides`/`@ContributesIntoMap`) into `AppScope`. +- Confirm `metro.generateContributionProviders=true` is present in `gradle.properties`. + **Dependency resolution failures:** -- Check `~/.gradle/gradle.properties` has GitHub PAT with `read:packages` -- See `scripts/ci-prebuild.sh` for required format +- Check `~/.gradle/gradle.properties` has GitHub PAT with `read:packages`. **Ktlint formatting errors:** ```bash -./gradlew ktlintFormat # Auto-fix +./gradlew ktlintFormat ``` ## Module Discovery @@ -527,4 +594,4 @@ Modules are auto-discovered via `settings.gradle.kts`: - **Configuration cache** enabled (incubating) - **Type-safe project accessors** for faster builds - **Parallel builds** supported -- **Dependency analysis** plugin monitors dependency health \ No newline at end of file +- **Dependency analysis** plugin monitors dependency health diff --git a/app/app/build.gradle.kts b/app/app/build.gradle.kts index 0d4a95a042..88e456445e 100644 --- a/app/app/build.gradle.kts +++ b/app/app/build.gradle.kts @@ -107,7 +107,6 @@ dependencies { implementation(platform(libs.firebase.bom)) implementation(libs.androidx.activity.compose) - implementation(libs.androidx.appstate) implementation(libs.androidx.compose.animationCore) implementation(libs.androidx.compose.foundation) implementation(libs.androidx.compose.material3.windowSizeClass) @@ -189,13 +188,16 @@ dependencies { implementation(projects.featureChat) implementation(projects.featureChipId) implementation(projects.featureChooseTier) + implementation(projects.featureChooseTierNavigation) implementation(projects.featureClaimChat) implementation(projects.featureClaimDetails) implementation(projects.featureClaimHistory) implementation(projects.featureConnectPaymentTrustly) + implementation(projects.featureConnectPaymentTrustlyNavigation) implementation(projects.featureCrossSellSheet) implementation(projects.featureDeleteAccount) implementation(projects.featureEditCoinsured) + implementation(projects.featureEditCoinsuredNavigation) implementation(projects.featureFlags) implementation(projects.featureForever) implementation(projects.featureHelpCenter) @@ -205,13 +207,16 @@ dependencies { implementation(projects.featureInsurances) implementation(projects.featureLogin) implementation(projects.featureMovingflow) + implementation(projects.featureMovingflowNavigation) implementation(projects.featureRemoveAddons) implementation(projects.featurePayoutAccount) implementation(projects.featurePayments) implementation(projects.featureProfile) implementation(projects.featureTerminateInsurance) + implementation(projects.featureTerminateInsuranceNavigation) implementation(projects.featureTravelCertificate) + implementation(projects.featureTravelCertificateNavigation) implementation(projects.foreverUi) implementation(projects.initializable) implementation(projects.languageCore) diff --git a/app/app/src/main/kotlin/com/hedvig/android/app/AndroidAppHost.kt b/app/app/src/main/kotlin/com/hedvig/android/app/AndroidAppHost.kt index 76742d731a..c044026719 100644 --- a/app/app/src/main/kotlin/com/hedvig/android/app/AndroidAppHost.kt +++ b/app/app/src/main/kotlin/com/hedvig/android/app/AndroidAppHost.kt @@ -1,6 +1,13 @@ package com.hedvig.android.app +import android.app.Activity +import androidx.activity.ComponentActivity import androidx.activity.SystemBarStyle +import androidx.activity.enableEdgeToEdge +import com.google.android.play.core.review.ReviewException +import com.google.android.play.core.review.ReviewManagerFactory +import com.hedvig.android.logger.LogPriority +import com.hedvig.android.logger.logcat /** * The Activity-bound capabilities the Compose app shell needs from its host. Implemented by @@ -16,3 +23,49 @@ internal interface AndroidAppHost { fun tryShowAppStoreReviewDialog() } + +internal class AndroidAppHostImpl(private val activity: ComponentActivity): AndroidAppHost { + override fun finishApp() = activity.finish() + + override fun applyEdgeToEdgeStyle(systemBarStyle: SystemBarStyle) { + activity.enableEdgeToEdge( + statusBarStyle = systemBarStyle, + navigationBarStyle = systemBarStyle, + ) + } + + override fun shouldShowPermissionRationale(permission: String): Boolean = + activity.shouldShowRequestPermissionRationale(permission) + + override fun tryShowAppStoreReviewDialog() = activity.tryShowPlayStoreReviewDialog() +} + +private fun Activity.tryShowPlayStoreReviewDialog() { + val tag = "PlayStoreReview" + val manager = ReviewManagerFactory.create(this) + logcat(LogPriority.INFO) { "$tag: requestReviewFlow" } + manager.requestReviewFlow().apply { + addOnFailureListener { logcat(LogPriority.INFO, it) { "$tag: requestReviewFlow failed:${it.message}" } } + addOnCanceledListener { logcat(LogPriority.INFO) { "$tag: requestReviewFlow cancelled" } } + addOnCompleteListener { task -> + if (task.isSuccessful) { + logcat(LogPriority.INFO) { "$tag: requestReviewFlow completed" } + val reviewInfo = task.result + logcat(LogPriority.INFO) { "$tag: launchReviewFlow with ReviewInfo:$reviewInfo" } + manager.launchReviewFlow(this@tryShowPlayStoreReviewDialog, reviewInfo).apply { + addOnFailureListener { logcat(LogPriority.INFO, it) { "$tag: launchReviewFlow failed:${it.message}" } } + addOnCanceledListener { logcat(LogPriority.INFO) { "$tag: launchReviewFlow canceled" } } + addOnCompleteListener { logcat(LogPriority.INFO) { "$tag: launchReviewFlow completed" } } + } + } else { + val exception = task.exception + val errorMessage = if (exception != null && exception is ReviewException) { + "ReviewException:${exception.message}. ReviewException::errorCode:${exception.errorCode}" + } else { + "Unknown error with message: ${exception?.message}" + } + logcat(LogPriority.INFO, exception) { "$tag: requestReviewFlow failed. Error:$errorMessage" } + } + } + } +} diff --git a/app/app/src/main/kotlin/com/hedvig/android/app/MainActivity.kt b/app/app/src/main/kotlin/com/hedvig/android/app/MainActivity.kt index 7b1e12b6c8..517c56db71 100644 --- a/app/app/src/main/kotlin/com/hedvig/android/app/MainActivity.kt +++ b/app/app/src/main/kotlin/com/hedvig/android/app/MainActivity.kt @@ -16,17 +16,11 @@ import androidx.appcompat.app.AppCompatDelegate import androidx.compose.foundation.ComposeFoundationFlags import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalContext import androidx.core.content.getSystemService import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle -import androidx.savedstate.SavedStateRegistry -import androidx.savedstate.serialization.SavedStateConfiguration -import androidx.savedstate.serialization.decodeFromSavedState -import androidx.savedstate.serialization.encodeToSavedState import coil3.ImageLoader import com.google.android.play.core.review.ReviewException import com.google.android.play.core.review.ReviewManagerFactory @@ -46,17 +40,12 @@ import com.hedvig.android.core.demomode.Provider import com.hedvig.android.core.rive.RiveInitializer import com.hedvig.android.data.paying.member.GetOnlyHasNonPayingContractsUseCase import com.hedvig.android.data.settings.datastore.SettingsDataStore -import com.hedvig.android.feature.login.navigation.LoginKey import com.hedvig.android.featureflags.FeatureManager import com.hedvig.android.language.LanguageLaunchCheckUseCase import com.hedvig.android.language.LanguageService import com.hedvig.android.logger.LogPriority import com.hedvig.android.logger.logcat -import com.hedvig.android.navigation.common.HedvigNavKey -import com.hedvig.android.navigation.common.StashedSession -import com.hedvig.android.navigation.common.TopLevelTab import com.hedvig.android.navigation.compose.HedvigDeepLinkMatcher -import com.hedvig.android.navigation.compose.merge import com.hedvig.android.notification.badge.data.payment.MissedPaymentNotificationService import com.hedvig.android.theme.Theme import dev.zacsweers.metro.Inject @@ -65,8 +54,6 @@ import java.util.Locale import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import kotlinx.serialization.Polymorphic -import kotlinx.serialization.Serializable import kotlinx.serialization.modules.SerializersModule class MainActivity : AppCompatActivity() { @@ -178,76 +165,28 @@ class MainActivity : AppCompatActivity() { addOnNewIntentListener { newIntent -> handleDeepLinkIntent(newIntent) } val externalNavigator = ExternalNavigatorImpl(this, hedvigBuildConstants.appPackageId) - val androidAppHost = object : AndroidAppHost { - override fun finishApp() = finish() - - override fun applyEdgeToEdgeStyle(systemBarStyle: SystemBarStyle) { - enableEdgeToEdge( - statusBarStyle = systemBarStyle, - navigationBarStyle = systemBarStyle, - ) - } - - override fun shouldShowPermissionRationale(permission: String): Boolean = - shouldShowRequestPermissionRationale(permission) - - override fun tryShowAppStoreReviewDialog() = tryShowPlayStoreReviewDialog() - } + val androidAppHost = AndroidAppHostImpl(this) RiveInitializer.init(this) - val savedStateConfiguration = SavedStateConfiguration { - serializersModule = serializersModules.merge() - } // Attach the Activity-bound task hooks to the app-scoped controller. Done here (not at // construction) and re-attached on every recreation: the singleton outlives any single Activity, // so capturing an Activity-bound lambda in the constructor would be the stale-reference leak we // set out to avoid. backstackController.isOwnTask = { isTaskRoot } backstackController.escapeToOwnTask = { parentStack -> - RestoredBackstackTransfer.escapeToOwnTask(this@MainActivity, parentStack, serializersModules) - } - val restoredBackstack = if (savedInstanceState != null) { - null - } else { - RestoredBackstackTransfer.readFrom(intent, serializersModules) - } - // Seed / restore the hoisted (app-scoped) navigation state. Precedence: - // 1. An explicit deep-link / escape re-root replaces everything. - // 2. Otherwise, on a cold start after process death, re-hydrate the full state from this - // Activity's SavedStateRegistry. On a config change the live singleton is already populated, - // so restoreFromSavedState is a no-op there and the live state wins. - // 3. Finally guarantee at least a Login root. - if (!restoredBackstack.isNullOrEmpty()) { - backstackController.reseed(restoredBackstack) - } else { - savedStateRegistry.consumeRestoredStateForKey(NAV_STATE_REGISTRY_KEY) - ?.let { decodeFromSavedState(NavStateSnapshot.serializer(), it, savedStateConfiguration) } - ?.let { snapshot -> - backstackController.restoreFromSavedState( - entries = snapshot.entries, - parkedRuns = snapshot.parkedRuns, - pendingDeepLink = snapshot.pendingDeepLink, - stashedSession = snapshot.stashedSession, - ) - } - backstackController.seedIfEmpty(listOf(LoginKey)) + NavigationStateBridge.escapeToOwnTask(this@MainActivity, parentStack, serializersModules) } - // Persist the live navigation state across process death. The provider is invoked at save time - // and serializes whatever the singleton holds then, so a Presenter-driven navigation is captured. - savedStateRegistry.registerSavedStateProvider( - NAV_STATE_REGISTRY_KEY, - SavedStateRegistry.SavedStateProvider { - encodeToSavedState( - NavStateSnapshot.serializer(), - NavStateSnapshot( - entries = backstackController.entries.toList(), - parkedRuns = backstackController.parkedRuns.toMap(), - pendingDeepLink = backstackController.pendingDeepLink, - stashedSession = backstackController.stashedSession, - ), - savedStateConfiguration, - ) - }, + backstackController.finishApp = androidAppHost::finishApp + NavigationStateBridge.restoreAndPersist( + backstackController = backstackController, + savedStateRegistry = savedStateRegistry, + intent = intent, + isColdStart = savedInstanceState == null, + serializersModules = serializersModules, ) + lifecycleScope.launch { + sessionReconciler.reconcile() + sessionReconciler.observeForcedLogout(lifecycle) + } setContent { CompositionLocalProvider( LocalMetroViewModelFactory provides (application as HedvigApplication).appGraph.metroViewModelFactory, @@ -255,7 +194,6 @@ class MainActivity : AppCompatActivity() { val windowSizeClass = calculateWindowSizeClass(this@MainActivity) HedvigApp( backstackController = backstackController, - sessionReconciler = sessionReconciler, deepLinkChannel = deepLinkChannel, windowSizeClass = windowSizeClass, settingsDataStore = settingsDataStore, @@ -323,50 +261,6 @@ private fun applyTheme(theme: Theme?, uiModeManager: UiModeManager?) { } } -private fun Activity.tryShowPlayStoreReviewDialog() { - val tag = "PlayStoreReview" - val manager = ReviewManagerFactory.create(this) - logcat(LogPriority.INFO) { "$tag: requestReviewFlow" } - manager.requestReviewFlow().apply { - addOnFailureListener { logcat(LogPriority.INFO, it) { "$tag: requestReviewFlow failed:${it.message}" } } - addOnCanceledListener { logcat(LogPriority.INFO) { "$tag: requestReviewFlow cancelled" } } - addOnCompleteListener { task -> - if (task.isSuccessful) { - logcat(LogPriority.INFO) { "$tag: requestReviewFlow completed" } - val reviewInfo = task.result - logcat(LogPriority.INFO) { "$tag: launchReviewFlow with ReviewInfo:$reviewInfo" } - manager.launchReviewFlow(this@tryShowPlayStoreReviewDialog, reviewInfo).apply { - addOnFailureListener { logcat(LogPriority.INFO, it) { "$tag: launchReviewFlow failed:${it.message}" } } - addOnCanceledListener { logcat(LogPriority.INFO) { "$tag: launchReviewFlow canceled" } } - addOnCompleteListener { logcat(LogPriority.INFO) { "$tag: launchReviewFlow completed" } } - } - } else { - val exception = task.exception - val errorMessage = if (exception != null && exception is ReviewException) { - "ReviewException:${exception.message}. ReviewException::errorCode:${exception.errorCode}" - } else { - "Unknown error with message: ${exception?.message}" - } - logcat(LogPriority.INFO, exception) { "$tag: requestReviewFlow failed. Error:$errorMessage" } - } - } - } -} - -private const val NAV_STATE_REGISTRY_KEY = "com.hedvig.android.app.NAV_STATE" - -/** - * The full hoisted navigation state, serialized into the Activity's SavedStateRegistry so the - * in-memory [BackstackController] singleton can be re-hydrated after process death. Mirrors the four - * holders the controller owns. - */ -@Serializable -private data class NavStateSnapshot( - val entries: List<@Polymorphic HedvigNavKey>, - val parkedRuns: Map>, - val pendingDeepLink: (@Polymorphic HedvigNavKey)?, - val stashedSession: StashedSession?, -) private fun getSystemLocale(config: android.content.res.Configuration): Locale { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { diff --git a/app/app/src/main/kotlin/com/hedvig/android/app/NavigationStateBridge.kt b/app/app/src/main/kotlin/com/hedvig/android/app/NavigationStateBridge.kt new file mode 100644 index 0000000000..dc85033309 --- /dev/null +++ b/app/app/src/main/kotlin/com/hedvig/android/app/NavigationStateBridge.kt @@ -0,0 +1,140 @@ +package com.hedvig.android.app + +import android.app.Activity +import android.content.Intent +import androidx.savedstate.SavedStateRegistry +import androidx.savedstate.serialization.SavedStateConfiguration +import androidx.savedstate.serialization.decodeFromSavedState +import androidx.savedstate.serialization.encodeToSavedState +import com.hedvig.android.app.navigation.BackstackController +import com.hedvig.android.feature.login.navigation.LoginKey +import com.hedvig.android.navigation.common.HedvigNavKey +import com.hedvig.android.navigation.common.StashedSession +import com.hedvig.android.navigation.common.TopLevelTab +import com.hedvig.android.navigation.compose.merge +import kotlinx.serialization.Polymorphic +import kotlinx.serialization.PolymorphicSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json +import kotlinx.serialization.modules.SerializersModule + +/** + * The single seam between an Activity lifecycle and the app-scoped [BackstackController]. Owns every + * way the navigation state crosses that seam, so the decision about what the stack should be at + * launch is made in one place rather than spread across `MainActivity`: + * + * - the Intent escape handoff that carries a backstack across an Activity relaunch ([escapeToOwnTask] + * writes it, [restoreAndPersist] reads it), + * - the process-death snapshot serialized into the Activity's `SavedStateRegistry` ([NavStateSnapshot]), + * - the launch-time seeding precedence and the save-provider registration ([restoreAndPersist]). + */ +internal object NavigationStateBridge { + private const val EXTRA_RESTORE_STACK = "com.hedvig.android.app.RESTORE_STACK" + private const val NAV_STATE_REGISTRY_KEY = "com.hedvig.android.app.NAV_STATE" + + private val handoffSerializer = ListSerializer(PolymorphicSerializer(HedvigNavKey::class)) + + /** + * Seeds / restores the hoisted navigation state, then registers the provider that persists it. + * The seeding precedence, read top to bottom: + * 1. An explicit deep-link / escape re-root (carried on the launch [intent]) replaces everything. + * 2. Otherwise, on a cold start after process death, re-hydrate the full state from the Activity's + * `SavedStateRegistry`. On a config change the live singleton is already populated, so + * [BackstackController.restoreFromSavedState] is a no-op and the live state wins. + * 3. Finally guarantee at least a Login root. + * + * [isColdStart] is `savedInstanceState == null`: a handoff only applies to a genuinely fresh launch. + */ + fun restoreAndPersist( + backstackController: BackstackController, + savedStateRegistry: SavedStateRegistry, + intent: Intent, + isColdStart: Boolean, + serializersModules: Set, + ) { + val savedStateConfiguration = SavedStateConfiguration { + this.serializersModule = serializersModules.merge() + } + + val handoff = if (isColdStart) { + readEscapeToOwnTaskHandoff(intent, serializersModules) + } else { + null + } + if (!handoff.isNullOrEmpty()) { + backstackController.reseed(handoff) + } else { + savedStateRegistry.consumeRestoredStateForKey(NAV_STATE_REGISTRY_KEY) + ?.let { decodeFromSavedState(NavStateSnapshot.serializer(), it, savedStateConfiguration) } + ?.let { snapshot -> + backstackController.restoreFromSavedState( + entries = snapshot.entries, + parkedRuns = snapshot.parkedRuns, + pendingDeepLink = snapshot.pendingDeepLink, + stashedSession = snapshot.stashedSession, + ) + } + backstackController.seedIfEmpty(listOf(LoginKey)) + } + + // Persist the live navigation state across process death. The provider is invoked at save time + // and serializes whatever the singleton holds then, so a Presenter-driven navigation is captured. + savedStateRegistry.registerSavedStateProvider(NAV_STATE_REGISTRY_KEY) { + encodeToSavedState( + NavStateSnapshot.serializer(), + NavStateSnapshot( + entries = backstackController.entries.toList(), + parkedRuns = backstackController.parkedRuns.toMap(), + pendingDeepLink = backstackController.pendingDeepLink, + stashedSession = backstackController.stashedSession, + ), + savedStateConfiguration, + ) + } + } + + /** + * Finishes this foreign-hosted instance and relaunches MainActivity in its own task, seeded with + * [parentStack]. The fresh instance reads the ancestry back in [restoreAndPersist] via the same + * extra key and codec, so the write/read contract can't drift. + */ + fun escapeToOwnTask( + activity: Activity, + parentStack: List, + serializersModules: Set, + ) { + val relaunch = Intent(activity, MainActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + putExtra(EXTRA_RESTORE_STACK, json(serializersModules).encodeToString(handoffSerializer, parentStack)) + } + activity.finish() + activity.startActivity(relaunch) + } + + /** The ancestry seeded by a prior [escapeToOwnTask], or null when [intent] carries no handoff. */ + private fun readEscapeToOwnTaskHandoff( + intent: Intent, + serializersModules: Set, + ): List? { + val encoded = intent.getStringExtra(EXTRA_RESTORE_STACK) ?: return null + return json(serializersModules).decodeFromString(handoffSerializer, encoded) + } + + private fun json(serializersModules: Set): Json = Json { + serializersModule = serializersModules.merge() + } +} + +/** + * The full hoisted navigation state, serialized into the Activity's SavedStateRegistry so the + * in-memory [BackstackController] singleton can be re-hydrated after process death. Mirrors the four + * holders the controller owns. + */ +@Serializable +private data class NavStateSnapshot( + val entries: List<@Polymorphic HedvigNavKey>, + val parkedRuns: Map>, + val pendingDeepLink: (@Polymorphic HedvigNavKey)?, + val stashedSession: StashedSession?, +) diff --git a/app/app/src/main/kotlin/com/hedvig/android/app/RestoredBackstackTransfer.kt b/app/app/src/main/kotlin/com/hedvig/android/app/RestoredBackstackTransfer.kt deleted file mode 100644 index f98e8a4b25..0000000000 --- a/app/app/src/main/kotlin/com/hedvig/android/app/RestoredBackstackTransfer.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.hedvig.android.app - -import android.app.Activity -import android.content.Intent -import com.hedvig.android.navigation.common.HedvigNavKey -import com.hedvig.android.navigation.compose.merge -import kotlinx.serialization.PolymorphicSerializer -import kotlinx.serialization.builtins.ListSerializer -import kotlinx.serialization.json.Json -import kotlinx.serialization.modules.SerializersModule - -/** - * The Intent-based handoff that carries a backstack across an Activity relaunch. - * - * When Up is pressed from a deep link hosted inside the launching app's task, [escapeToOwnTask] - * re-roots us in our own task (NEW_TASK|CLEAR_TASK + finish) and serializes the target ancestry into - * the launch intent. The fresh instance reads it back with [readFrom] to seed its initial backstack. - * Both sides share the same extra key and codec here so the write/read contract can't drift. - */ -internal object RestoredBackstackTransfer { - private const val EXTRA_RESTORE_STACK = "com.hedvig.android.app.RESTORE_STACK" - - private val serializer = ListSerializer(PolymorphicSerializer(HedvigNavKey::class)) - - /** Finishes this foreign-hosted instance and relaunches MainActivity in its own task, seeded with [parentStack]. */ - fun escapeToOwnTask( - activity: Activity, - parentStack: List, - serializersModules: Set, - ) { - val relaunch = Intent(activity, MainActivity::class.java).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) - putExtra(EXTRA_RESTORE_STACK, json(serializersModules).encodeToString(serializer, parentStack)) - } - activity.finish() - activity.startActivity(relaunch) - } - - /** The ancestry seeded by a prior [escapeToOwnTask], or null when [intent] carries no handoff. */ - fun readFrom(intent: Intent, serializersModules: Set): List? { - val encoded = intent.getStringExtra(EXTRA_RESTORE_STACK) ?: return null - return json(serializersModules).decodeFromString(serializer, encoded) - } - - private fun json(serializersModules: Set): Json = Json { - serializersModule = serializersModules.merge() - } -} diff --git a/app/app/src/main/kotlin/com/hedvig/android/app/navigation/BackstackController.kt b/app/app/src/main/kotlin/com/hedvig/android/app/navigation/BackstackController.kt index 0a64453447..2fec6c5dc4 100644 --- a/app/app/src/main/kotlin/com/hedvig/android/app/navigation/BackstackController.kt +++ b/app/app/src/main/kotlin/com/hedvig/android/app/navigation/BackstackController.kt @@ -1,7 +1,5 @@ package com.hedvig.android.app.navigation -import androidx.appstate.AppState -import androidx.appstate.AppStateKey import androidx.compose.runtime.MutableState import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue @@ -22,7 +20,6 @@ import com.hedvig.android.navigation.common.StashedSession import com.hedvig.android.navigation.common.TopLevelTab import com.hedvig.android.navigation.compose.Backstack import com.hedvig.android.navigation.compose.LoneDeepLinkChrome -import com.hedvig.android.navigation.compose.popBackstack import dev.zacsweers.metro.ContributesTo import dev.zacsweers.metro.Provides import dev.zacsweers.metro.SingleIn @@ -36,10 +33,9 @@ import dev.zacsweers.metro.SingleIn * `MainActivity.setContent`, so a config change recreated the Activity and deserialized it into a * brand-new instance — while Metro ViewModels survive a config change as the same instance via their * per-entry `ViewModelStore`. Handing a long-lived Presenter a reference to the composition-scoped - * stack would therefore go stale on the next rotation. Holding the state in an app-scoped [AppState] - * (see [BackstackControllerProviders]) makes this controller outlive every ViewModel, so a Presenter - * can mutate the live, rendered stack through [Backstack]. AppState is "just an object you choose the - * scope of" — here that scope is the application graph. + * stack would therefore go stale on the next rotation. Owning the snapshot state in this app-scoped + * singleton (see [BackstackControllerProviders]) makes the controller outlive every ViewModel, so a + * Presenter can mutate the live, rendered stack through [Backstack]. * * Process-death persistence is bridged at the Activity seam: an in-memory singleton is wiped when the * process dies, so `MainActivity` serializes the four holders into its `SavedStateRegistry` and @@ -69,6 +65,13 @@ internal class BackstackController( * the target stack. Attached/replaced by the Activity like [isOwnTask]; no-op by default. */ var escapeToOwnTask: (List) -> Unit = {}, + /** + * Finishes the host Activity, used by [popBackstack] when a Back/close lands on the root (nothing + * left to pop) so the app exits instead of stranding the user on a dead screen. Attached/replaced + * by the Activity like [isOwnTask] and [escapeToOwnTask]; no-op by default so unit tests and any + * pre-attach use never try to finish. + */ + var finishApp: () -> Unit = {}, ) : Backstack { /** * A deep link resolved while logged out, held until [setLoggedIn] consumes it (so it can land @@ -204,7 +207,19 @@ internal class BackstackController( } return true } - return popBackstack() + // Fall through to a *pure* pop (not our finishing override): an Up press at the root is a no-op, + // it must not exit the app the way Back does. + return super.popBackstack() + } + + /** + * Back/close pop. Pops the top entry; if there is nothing to pop (we are at the root) the Activity + * is finished so the app exits rather than leaving the user on a screen where Back does nothing. + */ + override fun popBackstack(): Boolean { + val popped = super.popBackstack() + if (!popped) finishApp() + return popped } /** @@ -313,29 +328,21 @@ private fun SnapshotStateList.replaceWith(target: List>() -private val ParkedRunsKey = AppStateKey>>() -private val PendingDeepLinkKey = AppStateKey>() -private val StashedSessionKey = AppStateKey>() - /** - * Wires the app-scoped navigation state. [AppState] is the process-lifetime store; the four nav - * holders are read out of it (created once, on first access) and handed to the singleton - * [BackstackController], which is exposed to feature Presenters as a plain [Backstack]. + * Wires the app-scoped navigation state. The four snapshot holders are created once and owned by the + * singleton [BackstackController] — their lifetime is the application graph, so they survive a config + * change while remaining the live objects the UI renders. The controller is exposed to feature + * Presenters as a plain [Backstack]. */ @ContributesTo(AppScope::class) internal interface BackstackControllerProviders { @Provides @SingleIn(AppScope::class) - fun provideAppState(): AppState = AppState() - - @Provides - @SingleIn(AppScope::class) - fun provideBackstackController(appState: AppState): BackstackController = BackstackController( - entries = appState.getState(EntriesKey, mutableStateListOf()).value, - parkedRuns = appState.getState(ParkedRunsKey, mutableStateMapOf()).value, - pendingDeepLinkState = appState.getState(PendingDeepLinkKey, mutableStateOf(null)).value, - stashedSessionState = appState.getState(StashedSessionKey, mutableStateOf(null)).value, + fun provideBackstackController(): BackstackController = BackstackController( + entries = mutableStateListOf(), + parkedRuns = mutableStateMapOf(), + pendingDeepLinkState = mutableStateOf(null), + stashedSessionState = mutableStateOf(null), ) @Provides diff --git a/app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigEntryProvider.kt b/app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigEntryProvider.kt index a7fe2f4851..663844822e 100644 --- a/app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigEntryProvider.kt +++ b/app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigEntryProvider.kt @@ -15,7 +15,6 @@ import com.hedvig.android.feature.addon.purchase.navigation.AddonPurchaseKey import com.hedvig.android.feature.addon.purchase.navigation.addonPurchaseEntries import com.hedvig.android.feature.change.tier.navigation.ChooseTierKey import com.hedvig.android.feature.change.tier.navigation.InsuranceCustomizationParameters -import com.hedvig.android.feature.change.tier.navigation.StartTierFlowChooseInsuranceKey import com.hedvig.android.feature.change.tier.navigation.StartTierFlowKey import com.hedvig.android.feature.change.tier.navigation.changeTierEntries import com.hedvig.android.feature.chat.navigation.ChatKey @@ -36,17 +35,6 @@ import com.hedvig.android.feature.editcoinsured.navigation.CoInsuredAddOrRemoveK import com.hedvig.android.feature.editcoinsured.navigation.EditCoInsuredTriageKey import com.hedvig.android.feature.editcoinsured.navigation.editCoInsuredEntries import com.hedvig.android.feature.forever.navigation.foreverEntries -import com.hedvig.android.feature.help.center.data.QuickLinkDestination.OuterDestination.ChooseInsuranceForEditCoInsured -import com.hedvig.android.feature.help.center.data.QuickLinkDestination.OuterDestination.ChooseInsuranceForEditCoOwners -import com.hedvig.android.feature.help.center.data.QuickLinkDestination.OuterDestination.QuickLinkChangeAddress -import com.hedvig.android.feature.help.center.data.QuickLinkDestination.OuterDestination.QuickLinkChangeTier -import com.hedvig.android.feature.help.center.data.QuickLinkDestination.OuterDestination.QuickLinkCoInsuredAddInfo -import com.hedvig.android.feature.help.center.data.QuickLinkDestination.OuterDestination.QuickLinkCoInsuredAddOrRemove -import com.hedvig.android.feature.help.center.data.QuickLinkDestination.OuterDestination.QuickLinkCoOwnerAddInfo -import com.hedvig.android.feature.help.center.data.QuickLinkDestination.OuterDestination.QuickLinkCoOwnerAddOrRemove -import com.hedvig.android.feature.help.center.data.QuickLinkDestination.OuterDestination.QuickLinkConnectPayment -import com.hedvig.android.feature.help.center.data.QuickLinkDestination.OuterDestination.QuickLinkTermination -import com.hedvig.android.feature.help.center.data.QuickLinkDestination.OuterDestination.QuickLinkTravelCertificate import com.hedvig.android.feature.help.center.helpCenterEntries import com.hedvig.android.feature.help.center.navigation.HelpCenterKey import com.hedvig.android.feature.home.home.navigation.homeEntries @@ -76,7 +64,6 @@ import com.hedvig.android.navigation.common.TopLevelTab import com.hedvig.android.navigation.compose.add import com.hedvig.android.navigation.compose.findLastOrNull import com.hedvig.android.navigation.compose.navigateAndPopUpTo -import com.hedvig.android.navigation.compose.popBackstack import com.hedvig.android.navigation.compose.popUpTo import com.hedvig.android.navigation.compose.removeAllOf import com.hedvig.feature.claim.chat.ClaimChatKey @@ -119,13 +106,6 @@ internal fun EntryProviderScope.hedvigEntryProvider( val onNavigateToImageViewer: (String, String) -> Unit = { imageUrl, cacheKey -> backstack.add(ImageViewerKey(imageUrl, cacheKey)) } - val popBackstackOrFinish = { - if (!backstack.popBackstack()) { - finishApp() - } - Unit - } - addLoginEntries(backstack, hedvigBuildConstants, openUrl, externalNavigator, scope, memberIdService) addHomeEntries( backstack = backstack, @@ -168,7 +148,6 @@ internal fun EntryProviderScope.hedvigEntryProvider( languageService = languageService, externalNavigator = externalNavigator, openUrl = openUrl, - popBackstackOrFinish = popBackstackOrFinish, navigateToConnectPayment = navigateToConnectPayment, navigateToPayoutAccount = navigateToPayoutAccount, navigateToNewConversation = navigateToNewConversation, @@ -187,10 +166,7 @@ internal fun EntryProviderScope.hedvigEntryProvider( imageLoader = imageLoader, openUrl = openUrl, externalNavigator = externalNavigator, - finishApp = finishApp, - popBackstackOrFinish = popBackstackOrFinish, navigateToNewConversation = navigateToNewConversation, - navigateToMovingFlow = navigateToMovingFlow, navigateToInbox = navigateToInbox, ) } @@ -460,7 +436,6 @@ private fun EntryProviderScope.addProfileEntries( languageService: LanguageService, externalNavigator: ExternalNavigator, openUrl: (String) -> Unit, - popBackstackOrFinish: () -> Unit, navigateToConnectPayment: () -> Unit, navigateToPayoutAccount: () -> Unit, navigateToNewConversation: () -> Unit, @@ -477,7 +452,6 @@ private fun EntryProviderScope.addProfileEntries( }, globalSnackBarState = globalSnackBarState, backstack = backstack, - popBackstackOrFinish = popBackstackOrFinish, hedvigBuildConstants = hedvigBuildConstants, navigateToConnectPayment = navigateToConnectPayment, navigateToConnectPayout = navigateToPayoutAccount, @@ -531,16 +505,11 @@ private fun EntryProviderScope.addSharedFlowEntries( imageLoader: ImageLoader, openUrl: (String) -> Unit, externalNavigator: ExternalNavigator, - finishApp: () -> Unit, - popBackstackOrFinish: () -> Unit, navigateToNewConversation: () -> Unit, - navigateToMovingFlow: () -> Unit, navigateToInbox: () -> Unit, ) { addonPurchaseEntries( backstack = backstack, - popBackstack = popBackstackOrFinish, - finishApp = finishApp, onNavigateToNewConversation = navigateToNewConversation, onNavigateToChangeTier = { contractId -> backstack.add(StartTierFlowKey(insuranceId = contractId)) @@ -554,7 +523,6 @@ private fun EntryProviderScope.addSharedFlowEntries( backstack = backstack, globalSnackBarState = globalSnackBarState, navigateUp = backstack::navigateUp, - popBackstackOrFinish = popBackstackOrFinish, goHome = { backstack.popUpTo(inclusive = true) backstack.selectTopLevel(TopLevelTab.Home) @@ -568,55 +536,6 @@ private fun EntryProviderScope.addSharedFlowEntries( helpCenterEntries( backstack = backstack, onNavigateUp = backstack::navigateUp, - onNavigateToQuickLink = onNavigateToQuickLink@{ quickLinkDestination -> - val destination: HedvigNavKey = when (quickLinkDestination) { - QuickLinkChangeAddress -> { - navigateToMovingFlow() - return@onNavigateToQuickLink - } - - is QuickLinkCoInsuredAddInfo -> { - CoInsuredAddInfoKey(quickLinkDestination.contractId, CoInsuredFlowType.CoInsured) - } - - is QuickLinkCoInsuredAddOrRemove -> { - CoInsuredAddOrRemoveKey(quickLinkDestination.contractId, CoInsuredFlowType.CoInsured) - } - - is QuickLinkCoOwnerAddInfo -> { - CoInsuredAddInfoKey(quickLinkDestination.contractId, CoInsuredFlowType.CoOwners) - } - - is QuickLinkCoOwnerAddOrRemove -> { - CoInsuredAddOrRemoveKey(quickLinkDestination.contractId, CoInsuredFlowType.CoOwners) - } - - QuickLinkConnectPayment -> { - TrustlyKey - } - - QuickLinkTermination -> { - TerminateInsuranceKey(null) - } - - QuickLinkTravelCertificate -> { - TravelCertificateKey - } - - QuickLinkChangeTier -> { - StartTierFlowChooseInsuranceKey - } - - ChooseInsuranceForEditCoInsured -> { - EditCoInsuredTriageKey() - } - - ChooseInsuranceForEditCoOwners -> { - EditCoInsuredTriageKey(type = CoInsuredFlowType.CoOwners) - } - } - backstack.add(destination) - }, onNavigateToNewConversation = navigateToNewConversation, onNavigateToInbox = navigateToInbox, openUrl = openUrl, diff --git a/app/app/src/main/kotlin/com/hedvig/android/app/navigation/SessionReconciler.kt b/app/app/src/main/kotlin/com/hedvig/android/app/navigation/SessionReconciler.kt index 02b908368f..e92b9b3ff8 100644 --- a/app/app/src/main/kotlin/com/hedvig/android/app/navigation/SessionReconciler.kt +++ b/app/app/src/main/kotlin/com/hedvig/android/app/navigation/SessionReconciler.kt @@ -3,6 +3,7 @@ package com.hedvig.android.app.navigation import androidx.compose.runtime.snapshotFlow import androidx.lifecycle.Lifecycle import androidx.lifecycle.repeatOnLifecycle +import arrow.core.identity import arrow.fx.coroutines.raceN import com.hedvig.android.auth.AuthStatus import com.hedvig.android.auth.AuthTokenService @@ -33,12 +34,14 @@ import kotlinx.coroutines.launch * longer hold tokens. It is lifecycle-gated so it only observes while the UI is STARTED. * * Deliberately narrow — only auth and the backstack root. Deep-links, notifications and the rest stay - * in their own observers. The [BackstackController] and the [Lifecycle] are handed in per call by the - * composition rather than injected, keeping this reconciler free of any Android/Compose lifetime. + * in their own observers. The [BackstackController] is an app-scoped singleton injected here; only the + * [Lifecycle] is handed in per call by the composition, keeping this reconciler free of any + * Android/Compose lifetime. */ @SingleIn(AppScope::class) @Inject internal class SessionReconciler( + private val backstackController: BackstackController, private val authTokenService: AuthTokenService, private val demoManager: DemoManager, private val memberIdService: MemberIdService, @@ -54,10 +57,10 @@ internal class SessionReconciler( * Resolves the start scene once, before the splash is dismissed. A no-op on subsequent calls because * [isReady] has already latched true. */ - suspend fun reconcile(backstackController: BackstackController) { + suspend fun reconcile() { if (isReadyState.value) return memberIdService.getMemberId().first()?.let { lastKnownMemberId = it } - determineStartScene(backstackController) + determineStartScene() isReadyState.value = true } @@ -65,14 +68,14 @@ internal class SessionReconciler( * Lifecycle-gated observers that keep the rendered root honest while the UI is STARTED: tracks the * latest member id and logs out when we leave demo mode without holding tokens. */ - suspend fun observeForcedLogout(backstackController: BackstackController, lifecycle: Lifecycle) { + suspend fun observeForcedLogout(lifecycle: Lifecycle) { lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { launch { memberIdService.getMemberId().collect { id -> if (id != null) lastKnownMemberId = id } } - logoutOnInvalidCredentials(backstackController) + logoutOnInvalidCredentials() } } @@ -81,7 +84,7 @@ internal class SessionReconciler( * restore the back stack already reflects the previous session, so a matching root is left untouched * and any deeper stack is preserved. */ - private suspend fun determineStartScene(backstackController: BackstackController) { + private suspend fun determineStartScene() { val showLoggedInScene = raceN( { authTokenService.authStatus.filterNotNull().first() is AuthStatus.LoggedIn }, { demoManager.isDemoMode().first { it } }, @@ -103,7 +106,7 @@ internal class SessionReconciler( * Automatically logs out when we are no longer in demo mode and we are also not considered to have * active tokens. */ - private suspend fun logoutOnInvalidCredentials(backstackController: BackstackController) { + private suspend fun logoutOnInvalidCredentials() { val authStatusLog: (AuthStatus?) -> Unit = { authStatus -> logcat { buildString { diff --git a/app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigApp.kt b/app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigApp.kt index de643d6f54..32fc094263 100644 --- a/app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigApp.kt +++ b/app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigApp.kt @@ -46,7 +46,6 @@ import com.hedvig.android.app.crosssell.CrossSellUriOpener import com.hedvig.android.app.crosssell.GetMemberAuthorizationCodeUseCase import com.hedvig.android.app.navigation.BackstackController import com.hedvig.android.app.navigation.CurrentDestinationHolder -import com.hedvig.android.app.navigation.SessionReconciler import com.hedvig.android.app.navigation.hedvigEntryProvider import com.hedvig.android.app.navigation.shouldFadeThrough import com.hedvig.android.app.urihandler.DeepLinkFirstUriHandler @@ -74,7 +73,6 @@ import com.hedvig.android.navigation.activity.ExternalNavigator import com.hedvig.android.navigation.common.HedvigNavKey import com.hedvig.android.navigation.compose.HedvigDeepLinkMatcher import com.hedvig.android.navigation.compose.entryDecorators -import com.hedvig.android.navigation.compose.popBackstack import com.hedvig.android.notification.badge.data.payment.MissedPaymentNotificationService import com.hedvig.android.ui.force.upgrade.ForceUpgradeBlockingScreen import hedvig.resources.EXIT_DEMO_MODE_BUTTON @@ -91,7 +89,6 @@ import org.jetbrains.compose.resources.stringResource @Composable internal fun HedvigApp( backstackController: BackstackController, - sessionReconciler: SessionReconciler, deepLinkChannel: Channel, windowSizeClass: WindowSizeClass, settingsDataStore: SettingsDataStore, @@ -122,10 +119,6 @@ internal fun HedvigApp( featureManager = featureManager, missedPaymentNotificationServiceProvider = missedPaymentNotificationServiceProvider, ) - val lifecycle = LocalLifecycleOwner.current.lifecycle - LaunchedEffect(sessionReconciler, backstackController) { - sessionReconciler.reconcile(backstackController) - } val darkTheme = hedvigAppState.darkTheme HedvigTheme(darkTheme = darkTheme) { EnableEdgeToEdgeSideEffect(darkTheme, splashIsRemovedSignal, androidAppHost::applyEdgeToEdgeStyle) @@ -135,9 +128,6 @@ internal fun HedvigApp( goToPlayStore = externalNavigator::tryOpenPlayStore, ) } else { - LaunchedEffect(sessionReconciler, backstackController, lifecycle) { - sessionReconciler.observeForcedLogout(backstackController, lifecycle) - } TryShowAppStoreReviewDialogEffect( authTokenService, waitUntilAppReviewDialogShouldBeOpenedUseCase, @@ -190,11 +180,7 @@ internal fun HedvigApp( GlobalHedvigSnackBar(globalSnackBarState = globalSnackBarState) NavDisplay( backStack = backstackController.entries, - onBack = { - if (!backstackController.popBackstack()) { - androidAppHost.finishApp() - } - }, + onBack = backstackController::popBackstack, entryDecorators = entryDecorators { backstackController.allLiveContentKeys }, sharedTransitionScope = this@SharedTransitionLayout, sceneDecoratorStrategies = sceneDecoratorStrategies, diff --git a/app/app/src/test/kotlin/com/hedvig/android/app/navigation/BackstackControllerTest.kt b/app/app/src/test/kotlin/com/hedvig/android/app/navigation/BackstackControllerTest.kt index 8cd49cec88..c2e0e9f89a 100644 --- a/app/app/src/test/kotlin/com/hedvig/android/app/navigation/BackstackControllerTest.kt +++ b/app/app/src/test/kotlin/com/hedvig/android/app/navigation/BackstackControllerTest.kt @@ -19,7 +19,6 @@ import com.hedvig.android.feature.profile.navigation.ProfileKey import com.hedvig.android.navigation.common.HedvigNavKey import com.hedvig.android.navigation.common.TopLevelTab import com.hedvig.android.navigation.compose.LoneDeepLinkChrome -import com.hedvig.android.navigation.compose.popBackstack import org.junit.Test internal class BackstackControllerTest { @@ -68,9 +67,17 @@ internal class BackstackControllerTest { } @Test - fun `system-back at the Home root exits the app`() { - val controller = controllerWith(HomeKey) + fun `system-back at the Home root finishes the app and keeps the root`() { + var finished = false + val controller = BackstackController( + mutableStateListOf(HomeKey), + mutableStateMapOf(), + mutableStateOf(null), + mutableStateOf(null), + finishApp = { finished = true }, + ) assertThat(controller.popBackstack()).isFalse() + assertThat(finished).isTrue() assertThat(controller.entries.toList()).containsExactly(HomeKey) } diff --git a/app/feature/feature-addon-purchase/src/main/kotlin/com/hedvig/android/feature/addon/purchase/navigation/AddonPurchaseEntries.kt b/app/feature/feature-addon-purchase/src/main/kotlin/com/hedvig/android/feature/addon/purchase/navigation/AddonPurchaseEntries.kt index 15dd3d907a..dcc9138a72 100644 --- a/app/feature/feature-addon-purchase/src/main/kotlin/com/hedvig/android/feature/addon/purchase/navigation/AddonPurchaseEntries.kt +++ b/app/feature/feature-addon-purchase/src/main/kotlin/com/hedvig/android/feature/addon/purchase/navigation/AddonPurchaseEntries.kt @@ -21,7 +21,6 @@ import com.hedvig.android.navigation.compose.Backstack import com.hedvig.android.navigation.compose.add import com.hedvig.android.navigation.compose.findLastOrNull import com.hedvig.android.navigation.compose.navigateAndPopUpTo -import com.hedvig.android.navigation.compose.popBackstack import com.hedvig.android.navigation.compose.popUpTo import dev.zacsweers.metrox.viewmodel.assistedMetroViewModel import kotlinx.serialization.Serializable @@ -35,8 +34,6 @@ internal data class PerilComparisonParams( fun EntryProviderScope.addonPurchaseEntries( backstack: Backstack, - popBackstack: () -> Unit, - finishApp: () -> Unit, onNavigateToNewConversation: () -> Unit, onNavigateToChangeTier: (contractId: String) -> Unit, ) { @@ -89,8 +86,7 @@ fun EntryProviderScope.addonPurchaseEntries( backstack = backstack, insuranceId = insuranceIds[0], preselectedAddonDisplayNames = preselectedAddonDisplayNames, - popBackstack = popBackstack, - finishApp = finishApp, + popBackstack = backstack::popBackstack, onNavigateToChangeTier = onNavigateToChangeTier, ) } else { @@ -101,7 +97,7 @@ fun EntryProviderScope.addonPurchaseEntries( SelectInsuranceForAddonDestination( viewModel = viewModel, navigateUp = backstack::navigateUp, - popBackstack = popBackstack, + popBackstack = backstack::popBackstack, ) } } @@ -111,8 +107,7 @@ fun EntryProviderScope.addonPurchaseEntries( backstack = backstack, insuranceId = key.insuranceId, preselectedAddonDisplayNames = key.preselectedAddonDisplayNames, - popBackstack = popBackstack, - finishApp = finishApp, + popBackstack = backstack::popBackstack, onNavigateToChangeTier = onNavigateToChangeTier, ) } @@ -135,20 +130,20 @@ fun EntryProviderScope.addonPurchaseEntries( AddonSummaryDestination( viewModel = viewModel, navigateUp = backstack::navigateUp, - navigateBack = popBackstack, + navigateBack = backstack::popBackstack, ) } entry { SubmitAddonFailureScreen( - popBackstack = popBackstack, + popBackstack = backstack::popBackstack, ) } entry { key -> SubmitAddonSuccessScreen( activationDate = key.activationDate, - popBackstack = popBackstack, + popBackstack = backstack::popBackstack, ) } } @@ -159,7 +154,6 @@ private fun CustomizeAddonContent( insuranceId: String, preselectedAddonDisplayNames: List, popBackstack: () -> Unit, - finishApp: () -> Unit, onNavigateToChangeTier: (contractId: String) -> Unit, ) { val viewModel: CustomizeAddonViewModel = @@ -171,12 +165,8 @@ private fun CustomizeAddonContent( navigateUp = backstack::navigateUp, popBackstack = popBackstack, popAddonFlow = { - // Drop everything above the anchor, then the anchor itself; if it was the root there is - // nothing left to show, so finish. backstack.popUpTo(inclusive = false) - if (!backstack.popBackstack()) { - finishApp() - } + backstack.popBackstack() }, onNavigateToTravelInsurancePlusExplanation = { perilData -> backstack.add(TravelInsurancePlusExplanationKey(perilData)) diff --git a/app/feature/feature-chip-id/src/main/kotlin/com/hedvig/android/feature/chip/id/navigation/ChipIdEntries.kt b/app/feature/feature-chip-id/src/main/kotlin/com/hedvig/android/feature/chip/id/navigation/ChipIdEntries.kt index 73df9e6ac6..7ff21c0c2d 100644 --- a/app/feature/feature-chip-id/src/main/kotlin/com/hedvig/android/feature/chip/id/navigation/ChipIdEntries.kt +++ b/app/feature/feature-chip-id/src/main/kotlin/com/hedvig/android/feature/chip/id/navigation/ChipIdEntries.kt @@ -18,7 +18,6 @@ fun EntryProviderScope.chipIdEntries( backstack: Backstack, globalSnackBarState: GlobalSnackBarState, navigateUp: () -> Unit, - popBackstackOrFinish: () -> Unit, goHome: () -> Unit, ) { entry { key -> @@ -40,7 +39,7 @@ fun EntryProviderScope.chipIdEntries( SelectInsuranceForChipIdDestination( viewModel = viewModel, navigateUp = navigateUp, - popBackstack = popBackstackOrFinish, + popBackstack = backstack::popBackstack, ) } diff --git a/app/feature/feature-choose-tier-navigation/build.gradle.kts b/app/feature/feature-choose-tier-navigation/build.gradle.kts new file mode 100644 index 0000000000..288520f5a2 --- /dev/null +++ b/app/feature/feature-choose-tier-navigation/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + id("hedvig.multiplatform.library") + id("hedvig.multiplatform.library.android") + id("hedvig.gradle.plugin") +} + +hedvig { + serialization() + navKeys() +} + +kotlin { + sourceSets { + commonMain.dependencies { + implementation(libs.kotlinx.serialization.core) + implementation(projects.coreCommonPublic) + implementation(projects.navigationCommon) + } + } +} diff --git a/app/feature/feature-choose-tier-navigation/src/commonMain/kotlin/com/hedvig/android/feature/change/tier/navigation/StartTierFlowChooseInsuranceKey.kt b/app/feature/feature-choose-tier-navigation/src/commonMain/kotlin/com/hedvig/android/feature/change/tier/navigation/StartTierFlowChooseInsuranceKey.kt new file mode 100644 index 0000000000..4e63abe773 --- /dev/null +++ b/app/feature/feature-choose-tier-navigation/src/commonMain/kotlin/com/hedvig/android/feature/change/tier/navigation/StartTierFlowChooseInsuranceKey.kt @@ -0,0 +1,7 @@ +package com.hedvig.android.feature.change.tier.navigation + +import com.hedvig.android.navigation.common.HedvigNavKey +import kotlinx.serialization.Serializable + +@Serializable +data object StartTierFlowChooseInsuranceKey : HedvigNavKey diff --git a/app/feature/feature-choose-tier/build.gradle.kts b/app/feature/feature-choose-tier/build.gradle.kts index 83f431eaac..e5eba9c1f8 100644 --- a/app/feature/feature-choose-tier/build.gradle.kts +++ b/app/feature/feature-choose-tier/build.gradle.kts @@ -36,6 +36,7 @@ dependencies { implementation(projects.designSystemHedvig) implementation(projects.featureFlags) implementation(projects.languageCore) + implementation(projects.featureChooseTierNavigation) implementation(projects.moleculePublic) implementation(projects.navigationCommon) implementation(projects.navigationCompose) diff --git a/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/navigation/ChooseTierEntries.kt b/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/navigation/ChooseTierEntries.kt index 4458d77873..a1015ffb64 100644 --- a/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/navigation/ChooseTierEntries.kt +++ b/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/navigation/ChooseTierEntries.kt @@ -14,7 +14,6 @@ import com.hedvig.android.feature.change.tier.ui.stepsummary.SubmitTierSuccessSc import com.hedvig.android.feature.change.tier.ui.stepsummary.SummaryViewModel import com.hedvig.android.navigation.common.HedvigNavKey import com.hedvig.android.navigation.compose.Backstack -import com.hedvig.android.navigation.compose.popBackstack import com.hedvig.android.navigation.compose.popUpTo import com.hedvig.android.shared.tier.comparison.ui.ComparisonDestination import com.hedvig.android.shared.tier.comparison.ui.ComparisonViewModel diff --git a/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/navigation/ChooseTierNavDestination.kt b/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/navigation/ChooseTierNavDestination.kt index 1356e04983..0a144f2a45 100644 --- a/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/navigation/ChooseTierNavDestination.kt +++ b/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/navigation/ChooseTierNavDestination.kt @@ -17,12 +17,6 @@ data class StartTierFlowKey( val insuranceId: String, ) : HedvigNavKey -/** - * The start of the flow, where we have can choose insurance to change its tier - */ -@Serializable -data object StartTierFlowChooseInsuranceKey : HedvigNavKey - @Serializable data class ChooseTierKey( /** diff --git a/app/feature/feature-claim-chat/src/androidMain/kotlin/com/hedvig/feature/claim/chat/ClaimChatEntries.kt b/app/feature/feature-claim-chat/src/androidMain/kotlin/com/hedvig/feature/claim/chat/ClaimChatEntries.kt index 184e9f1b52..372d695f78 100644 --- a/app/feature/feature-claim-chat/src/androidMain/kotlin/com/hedvig/feature/claim/chat/ClaimChatEntries.kt +++ b/app/feature/feature-claim-chat/src/androidMain/kotlin/com/hedvig/feature/claim/chat/ClaimChatEntries.kt @@ -8,7 +8,6 @@ import com.hedvig.android.navigation.common.HedvigNavKey import com.hedvig.android.navigation.compose.Backstack import com.hedvig.android.navigation.compose.add import com.hedvig.android.navigation.compose.navigateAndPopUpTo -import com.hedvig.android.navigation.compose.popBackstack import com.hedvig.android.ui.force.upgrade.ForceUpgradeBlockingScreen import com.hedvig.feature.claim.chat.data.ClaimIntentOutcome import com.hedvig.feature.claim.chat.data.StepContent diff --git a/app/feature/feature-connect-payment-trustly-navigation/build.gradle.kts b/app/feature/feature-connect-payment-trustly-navigation/build.gradle.kts new file mode 100644 index 0000000000..288520f5a2 --- /dev/null +++ b/app/feature/feature-connect-payment-trustly-navigation/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + id("hedvig.multiplatform.library") + id("hedvig.multiplatform.library.android") + id("hedvig.gradle.plugin") +} + +hedvig { + serialization() + navKeys() +} + +kotlin { + sourceSets { + commonMain.dependencies { + implementation(libs.kotlinx.serialization.core) + implementation(projects.coreCommonPublic) + implementation(projects.navigationCommon) + } + } +} diff --git a/app/feature/feature-connect-payment-trustly-navigation/src/commonMain/kotlin/com/hedvig/android/feature/connect/payment/trustly/ui/TrustlyKey.kt b/app/feature/feature-connect-payment-trustly-navigation/src/commonMain/kotlin/com/hedvig/android/feature/connect/payment/trustly/ui/TrustlyKey.kt new file mode 100644 index 0000000000..2292d02bef --- /dev/null +++ b/app/feature/feature-connect-payment-trustly-navigation/src/commonMain/kotlin/com/hedvig/android/feature/connect/payment/trustly/ui/TrustlyKey.kt @@ -0,0 +1,7 @@ +package com.hedvig.android.feature.connect.payment.trustly.ui + +import com.hedvig.android.navigation.common.HedvigNavKey +import kotlinx.serialization.Serializable + +@Serializable +data object TrustlyKey : HedvigNavKey diff --git a/app/feature/feature-connect-payment-trustly/build.gradle.kts b/app/feature/feature-connect-payment-trustly/build.gradle.kts index 7230d0df9a..d950d024a3 100644 --- a/app/feature/feature-connect-payment-trustly/build.gradle.kts +++ b/app/feature/feature-connect-payment-trustly/build.gradle.kts @@ -39,6 +39,7 @@ dependencies { implementation(projects.coreCommonPublic) implementation(projects.coreResources) implementation(projects.designSystemHedvig) + implementation(projects.featureConnectPaymentTrustlyNavigation) implementation(projects.moleculePublic) implementation(projects.navigationCommon) implementation(projects.navigationCompose) diff --git a/app/feature/feature-connect-payment-trustly/src/main/kotlin/com/hedvig/android/feature/connect/payment/trustly/navigation/ConnectTrustlyPaymentEntries.kt b/app/feature/feature-connect-payment-trustly/src/main/kotlin/com/hedvig/android/feature/connect/payment/trustly/navigation/ConnectTrustlyPaymentEntries.kt index 72c58dd3f1..7910944390 100644 --- a/app/feature/feature-connect-payment-trustly/src/main/kotlin/com/hedvig/android/feature/connect/payment/trustly/navigation/ConnectTrustlyPaymentEntries.kt +++ b/app/feature/feature-connect-payment-trustly/src/main/kotlin/com/hedvig/android/feature/connect/payment/trustly/navigation/ConnectTrustlyPaymentEntries.kt @@ -6,7 +6,6 @@ import com.hedvig.android.feature.connect.payment.trustly.ui.TrustlyDestination import com.hedvig.android.feature.connect.payment.trustly.ui.TrustlyKey import com.hedvig.android.navigation.common.HedvigNavKey import com.hedvig.android.navigation.compose.Backstack -import com.hedvig.android.navigation.compose.popBackstack import dev.zacsweers.metrox.viewmodel.metroViewModel fun EntryProviderScope.connectPaymentEntries(backstack: Backstack) { diff --git a/app/feature/feature-connect-payment-trustly/src/main/kotlin/com/hedvig/android/feature/connect/payment/trustly/ui/TrustlyDestination.kt b/app/feature/feature-connect-payment-trustly/src/main/kotlin/com/hedvig/android/feature/connect/payment/trustly/ui/TrustlyDestination.kt index f04cc59d65..6ae54cb5bc 100644 --- a/app/feature/feature-connect-payment-trustly/src/main/kotlin/com/hedvig/android/feature/connect/payment/trustly/ui/TrustlyDestination.kt +++ b/app/feature/feature-connect-payment-trustly/src/main/kotlin/com/hedvig/android/feature/connect/payment/trustly/ui/TrustlyDestination.kt @@ -46,19 +46,14 @@ import com.hedvig.android.feature.connect.payment.trustly.sdk.TrustlyWebView import com.hedvig.android.feature.connect.payment.trustly.sdk.TrustlyWebViewClient import com.hedvig.android.logger.logcat import com.hedvig.android.molecule.public.MoleculeViewModel -import com.hedvig.android.navigation.common.HedvigNavKey import hedvig.resources.Res import hedvig.resources.general_done_button import hedvig.resources.pay_in_confirmation_direct_debit_headline import hedvig.resources.pay_in_error_body import hedvig.resources.pay_in_explainer_direct_debit_headline import hedvig.resources.something_went_wrong -import kotlinx.serialization.Serializable import org.jetbrains.compose.resources.stringResource -@Serializable -data object TrustlyKey : HedvigNavKey - @Composable internal fun TrustlyDestination( viewModel: MoleculeViewModel, diff --git a/app/feature/feature-delete-account/src/main/kotlin/com/hedvig/android/feature/deleteaccount/DeleteAccountPresenter.kt b/app/feature/feature-delete-account/src/main/kotlin/com/hedvig/android/feature/deleteaccount/DeleteAccountPresenter.kt index 3ec4c5ed5d..3ab59ece66 100644 --- a/app/feature/feature-delete-account/src/main/kotlin/com/hedvig/android/feature/deleteaccount/DeleteAccountPresenter.kt +++ b/app/feature/feature-delete-account/src/main/kotlin/com/hedvig/android/feature/deleteaccount/DeleteAccountPresenter.kt @@ -15,7 +15,6 @@ import com.hedvig.android.feature.deleteaccount.data.RequestAccountDeletionUseCa import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.navigation.compose.Backstack -import com.hedvig.android.navigation.compose.popBackstack import kotlinx.coroutines.flow.collectLatest internal class DeleteAccountPresenter( @@ -64,8 +63,6 @@ internal class DeleteAccountPresenter( if (mutationResult.isLeft()) { failedToPerformAccountDeletion = true } else { - // Navigation driven directly from the Presenter via the injected app-scoped backstack — - // the whole point of hoisting the back stack into AppState. backstack.popBackstack() } isPerformingDeletion = false diff --git a/app/feature/feature-delete-account/src/main/kotlin/com/hedvig/android/feature/deleteaccount/DeleteAccountViewModel.kt b/app/feature/feature-delete-account/src/main/kotlin/com/hedvig/android/feature/deleteaccount/DeleteAccountViewModel.kt index b7b62a1661..a038a6b49c 100644 --- a/app/feature/feature-delete-account/src/main/kotlin/com/hedvig/android/feature/deleteaccount/DeleteAccountViewModel.kt +++ b/app/feature/feature-delete-account/src/main/kotlin/com/hedvig/android/feature/deleteaccount/DeleteAccountViewModel.kt @@ -20,9 +20,6 @@ import dev.zacsweers.metrox.viewmodel.ViewModelKey internal class DeleteAccountViewModel( private val requestAccountDeletionUseCase: RequestAccountDeletionUseCase, private val deleteAccountStateUseCase: DeleteAccountStateUseCase, - // The app-scoped backstack is injected straight into the ViewModel and handed to the Presenter, - // which can drive navigation itself (see DeleteAccountPresenter). It outlives this ViewModel, so it - // stays valid across config changes — no stale back stack. backstack: Backstack, ) : MoleculeViewModel( DeleteAccountUiState.Loading, diff --git a/app/feature/feature-delete-account/src/main/kotlin/com/hedvig/android/feature/deleteaccount/navigation/DeleteAccountEntries.kt b/app/feature/feature-delete-account/src/main/kotlin/com/hedvig/android/feature/deleteaccount/navigation/DeleteAccountEntries.kt index 5aab7dd325..3906d5f83e 100644 --- a/app/feature/feature-delete-account/src/main/kotlin/com/hedvig/android/feature/deleteaccount/navigation/DeleteAccountEntries.kt +++ b/app/feature/feature-delete-account/src/main/kotlin/com/hedvig/android/feature/deleteaccount/navigation/DeleteAccountEntries.kt @@ -5,7 +5,6 @@ import com.hedvig.android.feature.chat.DeleteAccountViewModel import com.hedvig.android.feature.deleteaccount.DeleteAccountDestination import com.hedvig.android.navigation.common.HedvigNavKey import com.hedvig.android.navigation.compose.Backstack -import com.hedvig.android.navigation.compose.popBackstack import dev.zacsweers.metrox.viewmodel.metroViewModel fun EntryProviderScope.deleteAccountEntries(backstack: Backstack) { diff --git a/app/feature/feature-edit-coinsured-navigation/build.gradle.kts b/app/feature/feature-edit-coinsured-navigation/build.gradle.kts new file mode 100644 index 0000000000..96aa07a337 --- /dev/null +++ b/app/feature/feature-edit-coinsured-navigation/build.gradle.kts @@ -0,0 +1,21 @@ +plugins { + id("hedvig.multiplatform.library") + id("hedvig.multiplatform.library.android") + id("hedvig.gradle.plugin") +} + +hedvig { + serialization() + navKeys() +} + +kotlin { + sourceSets { + commonMain.dependencies { + implementation(libs.kotlinx.serialization.core) + implementation(projects.coreCommonPublic) + implementation(projects.dataCoinsured) + implementation(projects.navigationCommon) + } + } +} diff --git a/app/feature/feature-edit-coinsured-navigation/src/commonMain/kotlin/com/hedvig/android/feature/editcoinsured/navigation/EditCoInsuredKeys.kt b/app/feature/feature-edit-coinsured-navigation/src/commonMain/kotlin/com/hedvig/android/feature/editcoinsured/navigation/EditCoInsuredKeys.kt new file mode 100644 index 0000000000..a41e88f2bb --- /dev/null +++ b/app/feature/feature-edit-coinsured-navigation/src/commonMain/kotlin/com/hedvig/android/feature/editcoinsured/navigation/EditCoInsuredKeys.kt @@ -0,0 +1,25 @@ +package com.hedvig.android.feature.editcoinsured.navigation + +import com.hedvig.android.data.coinsured.CoInsuredFlowType +import com.hedvig.android.navigation.common.HedvigNavKey +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class CoInsuredAddInfoKey( + val contractId: String, + val type: CoInsuredFlowType, +) : HedvigNavKey + +@Serializable +data class CoInsuredAddOrRemoveKey( + val contractId: String, + val type: CoInsuredFlowType, +) : HedvigNavKey + +@Serializable +data class EditCoInsuredTriageKey( + @SerialName("contractId") + val contractId: String? = null, + val type: CoInsuredFlowType = CoInsuredFlowType.CoInsured, +) : HedvigNavKey diff --git a/app/feature/feature-edit-coinsured/build.gradle.kts b/app/feature/feature-edit-coinsured/build.gradle.kts index ef27b87f0b..d536ad06c5 100644 --- a/app/feature/feature-edit-coinsured/build.gradle.kts +++ b/app/feature/feature-edit-coinsured/build.gradle.kts @@ -37,6 +37,7 @@ dependencies { implementation(projects.coreUiData) implementation(projects.designSystemApi) implementation(projects.designSystemHedvig) + implementation(projects.featureEditCoinsuredNavigation) implementation(projects.moleculePublic) implementation(projects.navigationCommon) implementation(projects.navigationCompose) diff --git a/app/feature/feature-edit-coinsured/src/main/kotlin/com/hedvig/android/feature/editcoinsured/navigation/EditCoInsuredDestinations.kt b/app/feature/feature-edit-coinsured/src/main/kotlin/com/hedvig/android/feature/editcoinsured/navigation/EditCoInsuredDestinations.kt index a1f0cc8375..beb0e2c5de 100644 --- a/app/feature/feature-edit-coinsured/src/main/kotlin/com/hedvig/android/feature/editcoinsured/navigation/EditCoInsuredDestinations.kt +++ b/app/feature/feature-edit-coinsured/src/main/kotlin/com/hedvig/android/feature/editcoinsured/navigation/EditCoInsuredDestinations.kt @@ -9,25 +9,6 @@ import kotlinx.datetime.LocalDate import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -@Serializable -data class CoInsuredAddInfoKey( - val contractId: String, - val type: CoInsuredFlowType, -) : HedvigNavKey - -@Serializable -data class CoInsuredAddOrRemoveKey( - val contractId: String, - val type: CoInsuredFlowType, -) : HedvigNavKey - -@Serializable -data class EditCoInsuredTriageKey( - @SerialName("contractId") - val contractId: String? = null, - val type: CoInsuredFlowType = CoInsuredFlowType.CoInsured, -) : HedvigNavKey - @Serializable internal data class EditCoOwnersTriageDeepLinkKey( @SerialName("contractId") diff --git a/app/feature/feature-edit-coinsured/src/main/kotlin/com/hedvig/android/feature/editcoinsured/navigation/EditCoInsuredEntries.kt b/app/feature/feature-edit-coinsured/src/main/kotlin/com/hedvig/android/feature/editcoinsured/navigation/EditCoInsuredEntries.kt index 97c6b1cc43..d49e221530 100644 --- a/app/feature/feature-edit-coinsured/src/main/kotlin/com/hedvig/android/feature/editcoinsured/navigation/EditCoInsuredEntries.kt +++ b/app/feature/feature-edit-coinsured/src/main/kotlin/com/hedvig/android/feature/editcoinsured/navigation/EditCoInsuredEntries.kt @@ -10,7 +10,6 @@ import com.hedvig.android.feature.editcoinsured.ui.triage.EditCoInsuredTriageDes import com.hedvig.android.feature.editcoinsured.ui.triage.EditCoInsuredTriageViewModel import com.hedvig.android.navigation.common.HedvigNavKey import com.hedvig.android.navigation.compose.Backstack -import com.hedvig.android.navigation.compose.popBackstack import dev.zacsweers.metrox.viewmodel.assistedMetroViewModel fun EntryProviderScope.editCoInsuredEntries(backstack: Backstack) { diff --git a/app/feature/feature-help-center/build.gradle.kts b/app/feature/feature-help-center/build.gradle.kts index 9548fd7dc2..6f58c659b1 100644 --- a/app/feature/feature-help-center/build.gradle.kts +++ b/app/feature/feature-help-center/build.gradle.kts @@ -46,11 +46,18 @@ kotlin { implementation(projects.coreBuildConstants) implementation(projects.coreCommonPublic) implementation(projects.coreResources) + implementation(projects.dataCoinsured) implementation(projects.dataContract) implementation(projects.dataConversations) implementation(projects.dataTermination) implementation(projects.designSystemHedvig) + implementation(projects.featureChooseTierNavigation) + implementation(projects.featureConnectPaymentTrustlyNavigation) + implementation(projects.featureEditCoinsuredNavigation) implementation(projects.featureFlags) + implementation(projects.featureMovingflowNavigation) + implementation(projects.featureTerminateInsuranceNavigation) + implementation(projects.featureTravelCertificateNavigation) implementation(projects.moleculePublic) implementation(projects.navigationCommon) implementation(projects.navigationCompose) @@ -63,7 +70,7 @@ kotlin { } jvmMain.dependencies { } - androidInstrumentedTest.dependencies { + jvmTest.dependencies { implementation(libs.apollo.testingSupport) implementation(libs.assertK) implementation(libs.coroutines.test) diff --git a/app/feature/feature-help-center/src/commonMain/kotlin/com/hedvig/android/feature/help/center/HelpCenterEntries.kt b/app/feature/feature-help-center/src/commonMain/kotlin/com/hedvig/android/feature/help/center/HelpCenterEntries.kt index 0e7708e7a7..6a1bbd0b11 100644 --- a/app/feature/feature-help-center/src/commonMain/kotlin/com/hedvig/android/feature/help/center/HelpCenterEntries.kt +++ b/app/feature/feature-help-center/src/commonMain/kotlin/com/hedvig/android/feature/help/center/HelpCenterEntries.kt @@ -6,7 +6,6 @@ import coil3.ImageLoader import com.hedvig.android.compose.ui.dropUnlessResumed import com.hedvig.android.feature.help.center.commonclaim.FirstVetDestination import com.hedvig.android.feature.help.center.commonclaim.emergency.EmergencyDestination -import com.hedvig.android.feature.help.center.data.QuickLinkDestination import com.hedvig.android.feature.help.center.home.HelpCenterHomeDestination import com.hedvig.android.feature.help.center.navigation.EmergencyKey import com.hedvig.android.feature.help.center.navigation.FirstVetKey @@ -26,14 +25,12 @@ import com.hedvig.android.feature.help.center.topic.HelpCenterTopicViewModel import com.hedvig.android.navigation.common.HedvigNavKey import com.hedvig.android.navigation.compose.Backstack import com.hedvig.android.navigation.compose.add -import com.hedvig.android.navigation.compose.popBackstack import dev.zacsweers.metrox.viewmodel.assistedMetroViewModel import dev.zacsweers.metrox.viewmodel.metroViewModel fun EntryProviderScope.helpCenterEntries( backstack: Backstack, onNavigateUp: () -> Unit, - onNavigateToQuickLink: (QuickLinkDestination.OuterDestination) -> Unit, onNavigateToInbox: () -> Unit, onNavigateToNewConversation: () -> Unit, openUrl: (String) -> Unit, @@ -50,9 +47,6 @@ fun EntryProviderScope.helpCenterEntries( onNavigateToQuestion = dropUnlessResumed { question -> navigateToQuestion(question, backstack) }, - onNavigateToQuickLink = dropUnlessResumed { destination -> - onNavigateToQuickLink(destination) - }, onNavigateToInbox = dropUnlessResumed { onNavigateToInbox() }, diff --git a/app/feature/feature-help-center/src/commonMain/kotlin/com/hedvig/android/feature/help/center/HelpCenterPresenter.kt b/app/feature/feature-help-center/src/commonMain/kotlin/com/hedvig/android/feature/help/center/HelpCenterPresenter.kt index f6cde0670b..90283e4bea 100644 --- a/app/feature/feature-help-center/src/commonMain/kotlin/com/hedvig/android/feature/help/center/HelpCenterPresenter.kt +++ b/app/feature/feature-help-center/src/commonMain/kotlin/com/hedvig/android/feature/help/center/HelpCenterPresenter.kt @@ -11,8 +11,13 @@ import androidx.compose.runtime.setValue import arrow.core.NonEmptyList import arrow.core.merge import arrow.core.toNonEmptyListOrNull +import com.hedvig.android.data.coinsured.CoInsuredFlowType import com.hedvig.android.data.conversations.HasAnyActiveConversationUseCase -import com.hedvig.android.feature.help.center.HelpCenterEvent.ClearNavigation +import com.hedvig.android.feature.change.tier.navigation.StartTierFlowChooseInsuranceKey +import com.hedvig.android.feature.connect.payment.trustly.ui.TrustlyKey +import com.hedvig.android.feature.editcoinsured.navigation.CoInsuredAddInfoKey +import com.hedvig.android.feature.editcoinsured.navigation.CoInsuredAddOrRemoveKey +import com.hedvig.android.feature.editcoinsured.navigation.EditCoInsuredTriageKey import com.hedvig.android.feature.help.center.HelpCenterEvent.ClearSearchQuery import com.hedvig.android.feature.help.center.HelpCenterEvent.NavigateToQuickAction import com.hedvig.android.feature.help.center.HelpCenterEvent.OnDismissQuickActionDialog @@ -28,11 +33,26 @@ import com.hedvig.android.feature.help.center.data.GetPuppyGuideUseCase import com.hedvig.android.feature.help.center.data.GetQuickLinksUseCase import com.hedvig.android.feature.help.center.data.InnerHelpCenterDestination import com.hedvig.android.feature.help.center.data.QuickLinkDestination +import com.hedvig.android.feature.help.center.data.QuickLinkDestination.OuterDestination.ChooseInsuranceForEditCoInsured +import com.hedvig.android.feature.help.center.data.QuickLinkDestination.OuterDestination.ChooseInsuranceForEditCoOwners +import com.hedvig.android.feature.help.center.data.QuickLinkDestination.OuterDestination.QuickLinkChangeAddress +import com.hedvig.android.feature.help.center.data.QuickLinkDestination.OuterDestination.QuickLinkChangeTier +import com.hedvig.android.feature.help.center.data.QuickLinkDestination.OuterDestination.QuickLinkCoInsuredAddInfo +import com.hedvig.android.feature.help.center.data.QuickLinkDestination.OuterDestination.QuickLinkCoInsuredAddOrRemove +import com.hedvig.android.feature.help.center.data.QuickLinkDestination.OuterDestination.QuickLinkCoOwnerAddInfo +import com.hedvig.android.feature.help.center.data.QuickLinkDestination.OuterDestination.QuickLinkCoOwnerAddOrRemove +import com.hedvig.android.feature.help.center.data.QuickLinkDestination.OuterDestination.QuickLinkConnectPayment +import com.hedvig.android.feature.help.center.data.QuickLinkDestination.OuterDestination.QuickLinkTermination +import com.hedvig.android.feature.help.center.data.QuickLinkDestination.OuterDestination.QuickLinkTravelCertificate import com.hedvig.android.feature.help.center.model.QuickAction import com.hedvig.android.feature.help.center.navigation.EmergencyKey import com.hedvig.android.feature.help.center.navigation.FirstVetKey +import com.hedvig.android.feature.movingflow.SelectContractForMovingKey +import com.hedvig.android.feature.terminateinsurance.navigation.TerminateInsuranceKey +import com.hedvig.android.feature.travelcertificate.navigation.TravelCertificateKey import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.navigation.common.HedvigNavKey import com.hedvig.android.navigation.compose.Backstack import com.hedvig.android.navigation.compose.add import kotlinx.coroutines.flow.collect @@ -54,8 +74,6 @@ internal sealed interface HelpCenterEvent { data class NavigateToQuickAction(val destination: QuickLinkDestination) : HelpCenterEvent - data object ClearNavigation : HelpCenterEvent - data object ReloadFAQAndQuickLinks : HelpCenterEvent } @@ -66,7 +84,6 @@ internal data class HelpCenterUiState( val selectedQuickAction: QuickAction?, val search: Search?, val showNavigateToInboxButton: Boolean, - val destinationToNavigate: QuickLinkDestination.OuterDestination? = null, val puppyGuide: PuppyGuidePresentation?, ) { data class QuickLink(val quickAction: QuickAction) @@ -153,26 +170,62 @@ internal class HelpCenterPresenter( } } - ClearNavigation -> { - selectedQuickAction = null - currentState = currentState.copy(destinationToNavigate = null) - } - is NavigateToQuickAction -> { selectedQuickAction = null - when (val destination = event.destination) { + val key: HedvigNavKey = when (val destination = event.destination) { is InnerHelpCenterDestination.FirstVet -> { - backstack.add(FirstVetKey(destination.sections)) + FirstVetKey(destination.sections) } is InnerHelpCenterDestination.QuickLinkSickAbroad -> { - backstack.add(EmergencyKey(destination.deflectData)) + EmergencyKey(destination.deflectData) + } + + QuickLinkChangeAddress -> { + SelectContractForMovingKey + } + + is QuickLinkCoInsuredAddInfo -> { + CoInsuredAddInfoKey(destination.contractId, CoInsuredFlowType.CoInsured) + } + + is QuickLinkCoInsuredAddOrRemove -> { + CoInsuredAddOrRemoveKey(destination.contractId, CoInsuredFlowType.CoInsured) + } + + is QuickLinkCoOwnerAddInfo -> { + CoInsuredAddInfoKey(destination.contractId, CoInsuredFlowType.CoOwners) + } + + is QuickLinkCoOwnerAddOrRemove -> { + CoInsuredAddOrRemoveKey(destination.contractId, CoInsuredFlowType.CoOwners) + } + + QuickLinkConnectPayment -> { + TrustlyKey + } + + QuickLinkTermination -> { + TerminateInsuranceKey(null) + } + + QuickLinkTravelCertificate -> { + TravelCertificateKey + } + + QuickLinkChangeTier -> { + StartTierFlowChooseInsuranceKey + } + + ChooseInsuranceForEditCoInsured -> { + EditCoInsuredTriageKey() } - is QuickLinkDestination.OuterDestination -> { - currentState = currentState.copy(destinationToNavigate = destination) + ChooseInsuranceForEditCoOwners -> { + EditCoInsuredTriageKey(type = CoInsuredFlowType.CoOwners) } } + backstack.add(key) } HelpCenterEvent.ReloadFAQAndQuickLinks -> { diff --git a/app/feature/feature-help-center/src/commonMain/kotlin/com/hedvig/android/feature/help/center/home/HelpCenterHomeDestination.kt b/app/feature/feature-help-center/src/commonMain/kotlin/com/hedvig/android/feature/help/center/home/HelpCenterHomeDestination.kt index 0bd5504f13..0bd5778c3a 100644 --- a/app/feature/feature-help-center/src/commonMain/kotlin/com/hedvig/android/feature/help/center/home/HelpCenterHomeDestination.kt +++ b/app/feature/feature-help-center/src/commonMain/kotlin/com/hedvig/android/feature/help/center/home/HelpCenterHomeDestination.kt @@ -134,20 +134,12 @@ internal fun HelpCenterHomeDestination( viewModel: HelpCenterViewModel, onNavigateToTopic: (topicId: String) -> Unit, onNavigateToQuestion: (questionId: String) -> Unit, - onNavigateToQuickLink: (QuickLinkDestination.OuterDestination) -> Unit, onNavigateUp: () -> Unit, onNavigateToInbox: () -> Unit, onNavigateToNewConversation: () -> Unit, onNavigateToPuppyGuide: () -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - LaunchedEffect(uiState.destinationToNavigate) { - val destination = uiState.destinationToNavigate - if (destination != null && uiState.selectedQuickAction == null) { - viewModel.emit(HelpCenterEvent.ClearNavigation) - onNavigateToQuickLink(destination) - } - } HelpCenterHomeScreen( topics = uiState.topics, questions = uiState.questions, diff --git a/app/feature/feature-help-center/src/test/kotlin/GetMemberActionsUseCaseImpl.kt b/app/feature/feature-help-center/src/jvmTest/kotlin/GetMemberActionsUseCaseImplTest.kt similarity index 100% rename from app/feature/feature-help-center/src/test/kotlin/GetMemberActionsUseCaseImpl.kt rename to app/feature/feature-help-center/src/jvmTest/kotlin/GetMemberActionsUseCaseImplTest.kt diff --git a/app/feature/feature-help-center/src/test/kotlin/GetQuickLinksUseCaseTest.kt b/app/feature/feature-help-center/src/jvmTest/kotlin/GetQuickLinksUseCaseTest.kt similarity index 100% rename from app/feature/feature-help-center/src/test/kotlin/GetQuickLinksUseCaseTest.kt rename to app/feature/feature-help-center/src/jvmTest/kotlin/GetQuickLinksUseCaseTest.kt diff --git a/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/navigation/HomeEntries.kt b/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/navigation/HomeEntries.kt index 703e6ab8f0..1378a541b8 100644 --- a/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/navigation/HomeEntries.kt +++ b/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/navigation/HomeEntries.kt @@ -12,7 +12,6 @@ import com.hedvig.android.navigation.common.HedvigNavKey import com.hedvig.android.navigation.compose.Backstack import com.hedvig.android.navigation.compose.NavSuiteSceneDecoratorStrategy import com.hedvig.android.navigation.compose.add -import com.hedvig.android.navigation.compose.popBackstack import dev.zacsweers.metrox.viewmodel.metroViewModel fun EntryProviderScope.homeEntries( diff --git a/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/navigation/InsuranceEntries.kt b/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/navigation/InsuranceEntries.kt index f211712073..8c3e7b4241 100644 --- a/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/navigation/InsuranceEntries.kt +++ b/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/navigation/InsuranceEntries.kt @@ -18,7 +18,6 @@ import com.hedvig.android.navigation.common.HedvigNavKey import com.hedvig.android.navigation.compose.Backstack import com.hedvig.android.navigation.compose.NavSuiteSceneDecoratorStrategy import com.hedvig.android.navigation.compose.add -import com.hedvig.android.navigation.compose.popBackstack import dev.zacsweers.metrox.viewmodel.assistedMetroViewModel import dev.zacsweers.metrox.viewmodel.metroViewModel diff --git a/app/feature/feature-movingflow-navigation/build.gradle.kts b/app/feature/feature-movingflow-navigation/build.gradle.kts new file mode 100644 index 0000000000..288520f5a2 --- /dev/null +++ b/app/feature/feature-movingflow-navigation/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + id("hedvig.multiplatform.library") + id("hedvig.multiplatform.library.android") + id("hedvig.gradle.plugin") +} + +hedvig { + serialization() + navKeys() +} + +kotlin { + sourceSets { + commonMain.dependencies { + implementation(libs.kotlinx.serialization.core) + implementation(projects.coreCommonPublic) + implementation(projects.navigationCommon) + } + } +} diff --git a/app/feature/feature-movingflow-navigation/src/commonMain/kotlin/com/hedvig/android/feature/movingflow/SelectContractForMovingKey.kt b/app/feature/feature-movingflow-navigation/src/commonMain/kotlin/com/hedvig/android/feature/movingflow/SelectContractForMovingKey.kt new file mode 100644 index 0000000000..d1f91ca9ad --- /dev/null +++ b/app/feature/feature-movingflow-navigation/src/commonMain/kotlin/com/hedvig/android/feature/movingflow/SelectContractForMovingKey.kt @@ -0,0 +1,7 @@ +package com.hedvig.android.feature.movingflow + +import com.hedvig.android.navigation.common.HedvigNavKey +import kotlinx.serialization.Serializable + +@Serializable +data object SelectContractForMovingKey : HedvigNavKey diff --git a/app/feature/feature-movingflow/build.gradle.kts b/app/feature/feature-movingflow/build.gradle.kts index 9172268f36..66b2de627a 100644 --- a/app/feature/feature-movingflow/build.gradle.kts +++ b/app/feature/feature-movingflow/build.gradle.kts @@ -34,6 +34,7 @@ dependencies { implementation(projects.dataProductVariantPublic) implementation(projects.designSystemHedvig) implementation(projects.featureFlags) + implementation(projects.featureMovingflowNavigation) implementation(projects.moleculePublic) implementation(projects.navigationCommon) implementation(projects.navigationCompose) diff --git a/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/MovingFlowEntries.kt b/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/MovingFlowEntries.kt index 6de7995c9e..23526ab193 100644 --- a/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/MovingFlowEntries.kt +++ b/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/MovingFlowEntries.kt @@ -16,7 +16,6 @@ import com.hedvig.android.feature.movingflow.ui.summary.SummaryDestination import com.hedvig.android.feature.movingflow.ui.summary.SummaryViewModel import com.hedvig.android.navigation.common.HedvigNavKey import com.hedvig.android.navigation.compose.Backstack -import com.hedvig.android.navigation.compose.popBackstack import com.hedvig.android.navigation.compose.popUpTo import com.hedvig.android.shared.tier.comparison.navigation.ComparisonParameters import com.hedvig.android.shared.tier.comparison.ui.ComparisonDestination @@ -26,9 +25,6 @@ import dev.zacsweers.metrox.viewmodel.metroViewModel import kotlinx.datetime.LocalDate import kotlinx.serialization.Serializable -@Serializable -data object SelectContractForMovingKey : HedvigNavKey - @Serializable internal data class HousingTypeKey(val moveIntentId: String) : HedvigNavKey diff --git a/app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/tab/ProfileEntries.kt b/app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/tab/ProfileEntries.kt index ded2664d3b..450149ec69 100644 --- a/app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/tab/ProfileEntries.kt +++ b/app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/tab/ProfileEntries.kt @@ -36,7 +36,6 @@ fun EntryProviderScope.profileEntries( nestedEntries: EntryProviderScope.() -> Unit, globalSnackBarState: GlobalSnackBarState, backstack: Backstack, - popBackstackOrFinish: () -> Unit, hedvigBuildConstants: HedvigBuildConstants, navigateToConnectPayment: () -> Unit, navigateToConnectPayout: () -> Unit, @@ -98,7 +97,7 @@ fun EntryProviderScope.profileEntries( viewModel = viewModel, globalSnackBarState = globalSnackBarState, navigateUp = backstack::navigateUp, - popBackstack = popBackstackOrFinish, + popBackstack = backstack::popBackstack, ) } entry { diff --git a/app/feature/feature-remove-addons/src/androidMain/kotlin/com/hedvig/feature/remove/addons/RemoveAddonsEntries.kt b/app/feature/feature-remove-addons/src/androidMain/kotlin/com/hedvig/feature/remove/addons/RemoveAddonsEntries.kt index f23ed0453d..e2a0a439f6 100644 --- a/app/feature/feature-remove-addons/src/androidMain/kotlin/com/hedvig/feature/remove/addons/RemoveAddonsEntries.kt +++ b/app/feature/feature-remove-addons/src/androidMain/kotlin/com/hedvig/feature/remove/addons/RemoveAddonsEntries.kt @@ -5,7 +5,6 @@ import androidx.navigation3.runtime.EntryProviderScope import com.hedvig.android.navigation.common.HedvigNavKey import com.hedvig.android.navigation.compose.Backstack import com.hedvig.android.navigation.compose.navigateAndPopUpTo -import com.hedvig.android.navigation.compose.popBackstack import com.hedvig.feature.remove.addons.ui.RemoveAddonFailureScreen import com.hedvig.feature.remove.addons.ui.RemoveAddonSuccessScreen import com.hedvig.feature.remove.addons.ui.RemoveAddonSummaryDestination diff --git a/app/feature/feature-terminate-insurance-navigation/build.gradle.kts b/app/feature/feature-terminate-insurance-navigation/build.gradle.kts new file mode 100644 index 0000000000..288520f5a2 --- /dev/null +++ b/app/feature/feature-terminate-insurance-navigation/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + id("hedvig.multiplatform.library") + id("hedvig.multiplatform.library.android") + id("hedvig.gradle.plugin") +} + +hedvig { + serialization() + navKeys() +} + +kotlin { + sourceSets { + commonMain.dependencies { + implementation(libs.kotlinx.serialization.core) + implementation(projects.coreCommonPublic) + implementation(projects.navigationCommon) + } + } +} diff --git a/app/feature/feature-terminate-insurance-navigation/src/commonMain/kotlin/com/hedvig/android/feature/terminateinsurance/navigation/TerminateInsuranceKey.kt b/app/feature/feature-terminate-insurance-navigation/src/commonMain/kotlin/com/hedvig/android/feature/terminateinsurance/navigation/TerminateInsuranceKey.kt new file mode 100644 index 0000000000..748c695e91 --- /dev/null +++ b/app/feature/feature-terminate-insurance-navigation/src/commonMain/kotlin/com/hedvig/android/feature/terminateinsurance/navigation/TerminateInsuranceKey.kt @@ -0,0 +1,11 @@ +package com.hedvig.android.feature.terminateinsurance.navigation + +import com.hedvig.android.navigation.common.HedvigNavKey +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class TerminateInsuranceKey( + @SerialName("contractId") + val insuranceId: String? = null, +) : HedvigNavKey diff --git a/app/feature/feature-terminate-insurance/build.gradle.kts b/app/feature/feature-terminate-insurance/build.gradle.kts index a642ab4263..a6eb950575 100644 --- a/app/feature/feature-terminate-insurance/build.gradle.kts +++ b/app/feature/feature-terminate-insurance/build.gradle.kts @@ -37,6 +37,7 @@ dependencies { implementation(projects.dataTermination) implementation(projects.designSystemHedvig) implementation(projects.featureFlags) + implementation(projects.featureTerminateInsuranceNavigation) implementation(projects.languageCore) implementation(projects.moleculePublic) implementation(projects.navigationCommon) diff --git a/app/feature/feature-terminate-insurance/src/main/kotlin/com/hedvig/android/feature/terminateinsurance/navigation/TerminateInsuranceDestination.kt b/app/feature/feature-terminate-insurance/src/main/kotlin/com/hedvig/android/feature/terminateinsurance/navigation/TerminateInsuranceDestination.kt index c21d30cb57..b8be42ba3a 100644 --- a/app/feature/feature-terminate-insurance/src/main/kotlin/com/hedvig/android/feature/terminateinsurance/navigation/TerminateInsuranceDestination.kt +++ b/app/feature/feature-terminate-insurance/src/main/kotlin/com/hedvig/android/feature/terminateinsurance/navigation/TerminateInsuranceDestination.kt @@ -7,15 +7,8 @@ import com.hedvig.android.feature.terminateinsurance.data.TerminationAction import com.hedvig.android.feature.terminateinsurance.data.TerminationSurveyOption import com.hedvig.android.navigation.common.HedvigNavKey import kotlinx.datetime.LocalDate -import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -@Serializable -data class TerminateInsuranceKey( - @SerialName("contractId") - val insuranceId: String? = null, -) : HedvigNavKey - @Serializable internal data class TerminationSurveyFirstStepKey( val options: List, diff --git a/app/feature/feature-terminate-insurance/src/main/kotlin/com/hedvig/android/feature/terminateinsurance/navigation/TerminateInsuranceEntries.kt b/app/feature/feature-terminate-insurance/src/main/kotlin/com/hedvig/android/feature/terminateinsurance/navigation/TerminateInsuranceEntries.kt index bc12af2803..fe3e1f69ee 100644 --- a/app/feature/feature-terminate-insurance/src/main/kotlin/com/hedvig/android/feature/terminateinsurance/navigation/TerminateInsuranceEntries.kt +++ b/app/feature/feature-terminate-insurance/src/main/kotlin/com/hedvig/android/feature/terminateinsurance/navigation/TerminateInsuranceEntries.kt @@ -21,9 +21,10 @@ import com.hedvig.android.feature.terminateinsurance.step.terminationreview.Term import com.hedvig.android.feature.terminateinsurance.step.terminationsuccess.TerminationSuccessDestination import com.hedvig.android.feature.terminateinsurance.step.unknown.UnknownScreenDestination import com.hedvig.android.navigation.common.HedvigNavKey +import com.hedvig.android.navigation.common.TopLevelTab import com.hedvig.android.navigation.compose.Backstack import com.hedvig.android.navigation.compose.add -import com.hedvig.android.navigation.compose.popBackstack +import com.hedvig.android.navigation.compose.popUpTo import dev.zacsweers.metrox.viewmodel.assistedMetroViewModel fun EntryProviderScope.terminateInsuranceEntries( @@ -59,7 +60,11 @@ fun EntryProviderScope.terminateInsuranceEntries( TerminationSuccessDestination( terminationDate = key.terminationDate, onDone = { - if (!backstack.popBackstack()) { + // Reached alone via deep link → land on the Insurances tab rather than exiting the app + // (popBackstack would finish the Activity at the root). + if (backstack.entries.size > 1) { + backstack.popBackstack() + } else { navigateToInsurances() } }, diff --git a/app/feature/feature-travel-certificate-navigation/build.gradle.kts b/app/feature/feature-travel-certificate-navigation/build.gradle.kts new file mode 100644 index 0000000000..288520f5a2 --- /dev/null +++ b/app/feature/feature-travel-certificate-navigation/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + id("hedvig.multiplatform.library") + id("hedvig.multiplatform.library.android") + id("hedvig.gradle.plugin") +} + +hedvig { + serialization() + navKeys() +} + +kotlin { + sourceSets { + commonMain.dependencies { + implementation(libs.kotlinx.serialization.core) + implementation(projects.coreCommonPublic) + implementation(projects.navigationCommon) + } + } +} diff --git a/app/feature/feature-travel-certificate-navigation/src/commonMain/kotlin/com/hedvig/android/feature/travelcertificate/navigation/TravelCertificateKey.kt b/app/feature/feature-travel-certificate-navigation/src/commonMain/kotlin/com/hedvig/android/feature/travelcertificate/navigation/TravelCertificateKey.kt new file mode 100644 index 0000000000..b4cf60c413 --- /dev/null +++ b/app/feature/feature-travel-certificate-navigation/src/commonMain/kotlin/com/hedvig/android/feature/travelcertificate/navigation/TravelCertificateKey.kt @@ -0,0 +1,8 @@ +package com.hedvig.android.feature.travelcertificate.navigation + +import com.hedvig.android.navigation.common.CrossSellEligibleDestination +import com.hedvig.android.navigation.common.HedvigNavKey +import kotlinx.serialization.Serializable + +@Serializable +data object TravelCertificateKey : HedvigNavKey, CrossSellEligibleDestination diff --git a/app/feature/feature-travel-certificate/build.gradle.kts b/app/feature/feature-travel-certificate/build.gradle.kts index 71892c4f96..9c7b048554 100644 --- a/app/feature/feature-travel-certificate/build.gradle.kts +++ b/app/feature/feature-travel-certificate/build.gradle.kts @@ -33,6 +33,7 @@ dependencies { implementation(projects.designSystemHedvig) implementation(projects.featureFlags) implementation(projects.languageCore) + implementation(projects.featureTravelCertificateNavigation) implementation(projects.moleculePublic) implementation(projects.navigationActivity) implementation(projects.navigationCommon) diff --git a/app/feature/feature-travel-certificate/src/main/kotlin/com/hedvig/android/feature/travelcertificate/navigation/TravelCertificateDestination.kt b/app/feature/feature-travel-certificate/src/main/kotlin/com/hedvig/android/feature/travelcertificate/navigation/TravelCertificateDestination.kt index 8c723973e5..73fd26094a 100644 --- a/app/feature/feature-travel-certificate/src/main/kotlin/com/hedvig/android/feature/travelcertificate/navigation/TravelCertificateDestination.kt +++ b/app/feature/feature-travel-certificate/src/main/kotlin/com/hedvig/android/feature/travelcertificate/navigation/TravelCertificateDestination.kt @@ -1,14 +1,10 @@ package com.hedvig.android.feature.travelcertificate.navigation import com.hedvig.android.feature.travelcertificate.data.TravelCertificateUrl -import com.hedvig.android.navigation.common.CrossSellEligibleDestination import com.hedvig.android.navigation.common.HedvigNavKey import kotlinx.datetime.LocalDate import kotlinx.serialization.Serializable -@Serializable -data object TravelCertificateKey : HedvigNavKey, CrossSellEligibleDestination - @Serializable internal data object TravelCertificateChooseContractKey : HedvigNavKey diff --git a/app/navigation/navigation-compose/src/commonMain/kotlin/com/hedvig/android/navigation/compose/Backstack.kt b/app/navigation/navigation-compose/src/commonMain/kotlin/com/hedvig/android/navigation/compose/Backstack.kt index 96e9f50a92..01734dae5f 100644 --- a/app/navigation/navigation-compose/src/commonMain/kotlin/com/hedvig/android/navigation/compose/Backstack.kt +++ b/app/navigation/navigation-compose/src/commonMain/kotlin/com/hedvig/android/navigation/compose/Backstack.kt @@ -19,18 +19,26 @@ interface Backstack { * task-aware synthetic-stack rebuilding for lone deep links (see BackstackController). */ fun navigateUp(): Boolean = popBackstack() + + /** + * Pops the top entry. Returns false when the back stack is already at its root (nothing popped). + * + * This default is a pure temporal pop. `:app`'s controller overrides it so that a pop at the root + * finishes the Activity instead of silently no-opping — i.e. Back/close from the last screen exits + * the app. A caller that needs a different at-root behavior (e.g. jump to another tab) must branch + * on the back stack itself rather than on this return value, since in `:app` the root case never + * returns: it finishes. + */ + fun popBackstack(): Boolean = Snapshot.withMutableSnapshot { + if (entries.size <= 1) return@withMutableSnapshot false + entries.removeAt(entries.lastIndex) + true + } } /** Pushes [key] onto the top of the back stack. */ fun Backstack.add(key: HedvigNavKey): Boolean = entries.add(key) -/** Pops the top entry. Returns false if the back stack is at its root (nothing popped). */ -fun Backstack.popBackstack(): Boolean = Snapshot.withMutableSnapshot { - if (entries.size <= 1) return@withMutableSnapshot false - entries.removeAt(entries.lastIndex) - true -} - /** Pops up to (and optionally including) the most recent entry of [T]. No-op if absent. */ inline fun Backstack.popUpTo(inclusive: Boolean) { val index = entries.indexOfLast { it is T } diff --git a/app/navigation/navigation-keys-processor/src/main/kotlin/com/hedvig/android/navigation/keys/processor/NavKeySerializerProcessor.kt b/app/navigation/navigation-keys-processor/src/main/kotlin/com/hedvig/android/navigation/keys/processor/NavKeySerializerProcessor.kt index 8bdf1e7c2c..3f99ff784e 100644 --- a/app/navigation/navigation-keys-processor/src/main/kotlin/com/hedvig/android/navigation/keys/processor/NavKeySerializerProcessor.kt +++ b/app/navigation/navigation-keys-processor/src/main/kotlin/com/hedvig/android/navigation/keys/processor/NavKeySerializerProcessor.kt @@ -90,10 +90,15 @@ class NavKeySerializerProcessor( val packageName = keys.first().packageName.asString() // The generated interface is merged into the app-wide Metro graph alongside every other module's - // generated provider. A shared method name would make the graph inherit conflicting same-signature - // default methods (a diamond), so derive a per-package token to keep the interface and provide - // method names unique across modules. - val uniqueToken = packageName.toUniqueToken() + // generated provider, so its name and provide-method name must be unique across modules or the + // graph inherits a duplicate type / same-signature method diamond. The package alone is not enough: + // a feature and its `feature-x-navigation` sister legitimately share a package (e.g. `.navigation`). + // A nav-key class is declared in exactly one module and KSP only processes the current compilation's + // declarations, so this module's set of key FQNs is globally unique by construction — fold it into + // the token. This keeps the processor self-contained (no Gradle module name to inject) and impossible + // to silently de-duplicate wrong. + val keyFqns = keys.mapNotNull { it.qualifiedName?.asString() } + val uniqueToken = "${packageName.toUniqueToken()}_${keyFqns.joinToString("\n").stableHash()}" val provideFunction = FunSpec.builder("provide${uniqueToken}NavKeySerializersModule") .addAnnotation(provides) @@ -138,6 +143,18 @@ private fun String.toUniqueToken(): String = split('.') .dropWhile { it == "com" || it == "hedvig" } .joinToString("") { segment -> segment.replaceFirstChar { it.uppercaseChar() } } +// Deterministic, dependency-free FNV-1a 32-bit hash rendered as hex. Stable across machines and JVM +// versions (unlike a salted hash), and only changes when the module's key set changes — which already +// regenerates this file anyway. +private fun String.stableHash(): String { + var hash = -2128831035 // FNV-1a 32-bit offset basis + for (char in this) { + hash = hash xor char.code + hash *= 16777619 // FNV-1a 32-bit prime + } + return Integer.toHexString(hash) +} + class NavKeySerializerProcessorProvider : SymbolProcessorProvider { override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor = NavKeySerializerProcessor(environment) diff --git a/build-logic/convention/src/main/kotlin/HedvigGradlePlugin.kt b/build-logic/convention/src/main/kotlin/HedvigGradlePlugin.kt index dbbe7fdea0..4968eedaf4 100644 --- a/build-logic/convention/src/main/kotlin/HedvigGradlePlugin.kt +++ b/build-logic/convention/src/main/kotlin/HedvigGradlePlugin.kt @@ -87,6 +87,13 @@ private fun Project.configureFeatureModuleGuidelines() { fun String.isFeatureModule(): Boolean { return startsWith("feature-") && !startsWith("feature-flags") } + // A `feature-x-navigation` module hosts only the nav keys of `feature-x` that are meant to be + // reached from other feature modules. It is intentionally depend-able cross-feature so that a + // presenter can navigate directly to another feature's entry point, while the owning feature + // keeps its entries/screens and internal-only keys private. + fun String.isFeatureNavigationModule(): Boolean { + return isFeatureModule() && endsWith("-navigation") + } val thisModuleName = this.name if (!thisModuleName.isFeatureModule()) return @@ -95,13 +102,16 @@ private fun Project.configureFeatureModuleGuidelines() { eachDependency { if (requested.group != "hedvigandroid") return@eachDependency // Only check for our own modules if (requested.name == thisModuleName) return@eachDependency // Only check deps to other modules + // A `-navigation` module exists precisely to be depended on across features. + if (requested.name.isFeatureNavigationModule()) return@eachDependency val requestedModuleIsAFeatureModule = requested.name.isFeatureModule() require(!requestedModuleIsAFeatureModule) { "Hedvig build error on a module marked as featureModule() in HGP." + "\nYou are trying to depend on another feature module from a feature module." + "\nThis is not allowed as it breaks our ability to properly share code between modules." + "\nIn particular, $thisModuleName is trying to depend on ${requested.name}." + - "\nIf you need to share code between feature modules, consider moving the shared code to a library module." + "\nIf you need to share code between feature modules, consider moving the shared code to a library module." + + "\nIf you only need that feature's navigation keys, depend on its `feature-x-navigation` module instead." } } } diff --git a/docs/architecture/navigation-and-di.md b/docs/architecture/navigation-and-di.md new file mode 100644 index 0000000000..683a18203c --- /dev/null +++ b/docs/architecture/navigation-and-di.md @@ -0,0 +1,818 @@ +# Navigation & Dependency Injection — Engineering Design Spec + +> **Status:** current as of the Metro + Navigation 3 migration (PR #2976 and the follow-up work that landed on `develop`). +> +> **Audience:** every engineer who will touch this codebase. This document explains not just *what* the architecture is, but *why* each decision was made, what was rejected, and which invariants you must not break. The day-to-day quick reference is `CLAUDE.md`; this is the deep dive. + +--- + +## Table of contents + +1. [Why this migration happened](#1-why-this-migration-happened) +2. [The 30-second mental model](#2-the-30-second-mental-model) +3. [Part I — Dependency Injection with Metro](#part-i--dependency-injection-with-metro) +4. [Part II — Navigation 3 and the single back stack](#part-ii--navigation-3-and-the-single-back-stack) +5. [Part III — State persistence and the Activity seam](#part-iii--state-persistence-and-the-activity-seam) +6. [Part IV — Sessions, auth, and the splash gate](#part-iv--sessions-auth-and-the-splash-gate) +7. [Part V — Deep links and task management](#part-v--deep-links-and-task-management) +8. [Part VI — Invariants, decisions, and tests](#part-vi--invariants-decisions-and-tests) +9. [Part VII — How to do common things](#part-vii--how-to-do-common-things) +10. [Glossary](#glossary) + +--- + +## 1. Why this migration happened + +Two long-standing pain points drove this work. + +**DI: Koin was runtime-resolved.** With ~80 modules, a missing or mis-scoped binding surfaced as a crash at first use, not at build time. Koin also has no real story for Kotlin Multiplatform DI graphs, and we are pushing more and more shared code onto iOS via the umbrella framework. We wanted *compile-time* verification of the object graph and a contribution model that scales across modules without a central "god module" listing every binding. + +**Navigation: Nav2 fought us.** Jetpack Navigation 2 is built around a `NavController` that owns an opaque back stack of route strings. We repeatedly needed to do things the framework resisted: + +- Drive navigation from long-lived **Presenters** (Molecule), not just from composables holding a `NavController`. +- Implement **multiple back stacks** (per bottom-nav tab) with full state retention, including across process death. +- Build **synthetic back stacks** for deep links with correct Up behaviour, including the "we were launched into a foreign app's task" case. +- Keep navigation **type-safe** and KMP-friendly without route-string parsing. + +Navigation 3 (`androidx.navigation3`) inverts the ownership: *you* own the back stack as a plain observable list, and `NavDisplay` simply renders it. That inversion is exactly what let us move back-stack ownership into an app-scoped singleton and drive it from Presenters. Metro and Nav3 were adopted together because they reinforce each other — the app-scoped back stack controller is a Metro singleton, and feature serializer registrations are Metro multibindings generated by a KSP processor. + +The net effect: **the framework no longer owns navigation state — we do.** Everything in Part II follows from that single inversion. + +--- + +## 2. The 30-second mental model + +``` + ┌───────────────────────────────────────────────┐ + │ AppGraph │ + │ (single Metro @DependencyGraph, AppScope) │ + │ │ + feature modules ──────▶ @ContributesBinding / @Provides / @IntoSet/Map │ + contribute bindings │ │ + │ ┌──────────────────────┐ ┌───────────────┐ │ + │ │ BackstackController │ │ AppViewModel │ │ + │ │ @SingleIn(AppScope) │ │ Factory │ │ + │ │ owns ALL nav state │ └───────────────┘ │ + │ └───────────┬──────────┘ │ + └───────────────┼─────────────────────────────────┘ + │ entries: SnapshotStateList + ▼ + MainActivity ──seed/restore/persist──▶ NavigationStateBridge + │ (Activity ↔ singleton seam) + │ setContent + ▼ + HedvigApp ──▶ NavDisplay(backStack = controller.entries, entryProvider = hedvigEntryProvider { … }) +``` + +- **One Metro graph** (`AppScope`). Bindings are contributed from anywhere and merged at compile time. +- **One back stack** — `SnapshotStateList` owned by the app-scoped `BackstackController`. +- **One `NavDisplay`** in `HedvigApp` rendering that list. +- Destinations are `@Serializable` keys (`HedvigNavKey`). The KSP processor makes them survive process death. +- Presenters mutate navigation through the `Backstack` interface; the UI uses the concrete controller. + +--- + +# Part I — Dependency Injection with Metro + +## I.1 One graph, one scope + +There is a single `@DependencyGraph(AppScope::class)` for the whole Android app, declared in `:app`: + +```kotlin +@DependencyGraph(AppScope::class) +internal interface AppGraph : ViewModelGraph { + val workerFactory: MetroWorkerFactory + val hedvigBuildConstants: HedvigBuildConstants + + @Multibinds(allowEmpty = true) + val serializersModules: Set + + fun inject(activity: MainActivity) + fun inject(application: HedvigApplication) + fun inject(service: PushNotificationService) + + @DependencyGraph.Factory + interface Factory { + fun create(@Provides applicationContext: Context): AppGraph + } +} +``` + +`AppScope` itself is deliberately trivial — a marker, nothing more: + +```kotlin +abstract class AppScope private constructor() +``` + +**Decision: no subscoping.** We did *not* introduce per-feature or per-screen scopes. Everything is `AppScope`. The reasons: + +- Subscoping in DI was historically used to bound object lifetimes to a screen. In this app, **screen lifetime is owned by Navigation 3's per-entry `ViewModelStore`**, not by DI. A screen's state lives in its ViewModel (resolved through the Metro ViewModel factory), which Nav3 disposes when the entry leaves the back stack. So DI scoping for screen lifetime would be redundant with the navigation layer. +- A single scope means a binding contributed by any module is visible everywhere, with no "which subgraph am I in" cognitive overhead. +- Compile-time graph validation catches the mistakes subscoping was protecting against. + +If you ever feel you need a narrower scope, first check whether the thing you're scoping is really screen-lived — if so, it belongs in a ViewModel, not a DI subscope. + +## I.2 The contribution model + +Bindings are never listed centrally. Each module *contributes* into `AppScope`, and Metro merges them at compile time. + +| You have… | Use… | Example | +|---|---|---| +| A class whose constructor you control, implementing one interface | `@ContributesBinding(AppScope::class)` on the impl, `@Inject` constructor | repositories, use cases, `AppViewModelFactory` | +| A binding you can't annotate a constructor on (framework type, builder, configured object) | `@Provides` in a `@ContributesTo(AppScope::class) interface` | `ApplicationMetroProviders` (ImageLoader, SharedPreferences, Clock, …) | +| A singleton | add `@SingleIn(AppScope::class)` *wherever the binding is declared* — on the `@Inject` constructor, or on the `@Provides` method when you can't annotate the constructor | `SessionReconciler` (on its `@Inject` constructor); `BackstackController` (on the `@Provides` in `BackstackControllerProviders`, since its constructor takes hand-built state holders — see II.4) | +| Many implementations collected into a set | `@ContributesIntoSet` | `SerializersModule`s, `DeepLinkMatcherProvider`s | +| Many implementations collected into a keyed map | `@ContributesIntoMap` + a `@MapKey` | ViewModels, workers | +| A collection that may legitimately be empty | declare it `@Multibinds(allowEmpty = true)` on the graph | `Set` | +| Runtime args + injected deps | `@AssistedInject` + `@AssistedFactory` | a screen ViewModel taking a `contractId` | + +`ApplicationMetroProviders` is the canonical example of the `@Provides`-in-`@ContributesTo` pattern — every `@Provides` there is `@SingleIn(AppScope::class)` and builds an Android/third-party object the graph needs (Coil `ImageLoader`, Media3 `SimpleCache`, `SharedPreferences`, clocks, the `HedvigDeepLinkMatcher` folded from a multibound set, …). + +**Why this matters:** a feature module adds a binding simply by annotating a class in its own source set. No edit to `:app`, no central registry to keep in sync, no merge conflicts on a god-module. The graph is assembled by the compiler. + +## I.3 ViewModels through Metro + +We use the `metro-viewmodel` integration (`dev.zacsweers.metrox.viewmodel`). The graph implements `ViewModelGraph`, and a single `AppViewModelFactory` fans out to three multibound maps: + +```kotlin +@ContributesBinding(AppScope::class) +@Inject +class AppViewModelFactory( + override val viewModelProviders: Map, () -> ViewModel>, + override val assistedFactoryProviders: Map, () -> ViewModelAssistedFactory>, + override val manualAssistedFactoryProviders: + Map, () -> ManualViewModelAssistedFactory>, +) : MetroViewModelFactory() +``` + +`MainActivity` publishes it into the composition: + +```kotlin +CompositionLocalProvider( + LocalMetroViewModelFactory provides appGraph.metroViewModelFactory, +) { HedvigApp(...) } +``` + +Inside an `entry { }` block you then resolve a ViewModel with one of two helpers: + +```kotlin +// No runtime args — registered with @Inject + @ContributesIntoMap + @ViewModelKey(MyVm::class) +val vm: InsuranceViewModel = metroViewModel() + +// Assisted (navigation) args — registered with the factory pattern below +val vm: ContractDetailViewModel = + assistedMetroViewModel { + create(key.contractId) + } +``` + +A screen taking navigation arguments registers like this (note the three annotations on the factory): + +```kotlin +@AssistedInject +internal class ContractDetailViewModel( + @Assisted contractId: String, + featureManager: FeatureManager, + useCase: GetContractForContractIdUseCase, +) : MoleculeViewModel( + initialState = ContractDetailsUiState.Loading, + presenter = ContractDetailPresenter(contractId, featureManager, useCase), + ) { + @AssistedFactory + @ManualViewModelAssistedFactoryKey + @ContributesIntoMap(AppScope::class) + fun interface Factory : ManualViewModelAssistedFactory { + fun create(@Assisted contractId: String): ContractDetailViewModel + } +} +``` + +**Why "manual" assisted factories.** `@ManualViewModelAssistedFactoryKey` keys the factory by the *factory type* you name at the call site, letting `assistedMetroViewModel` pick exactly the right one. The ViewModel just constructs its Presenter, passing the assisted arg straight through. The Presenter never sees DI — it receives plain typed dependencies, exactly as before the migration. + +**The ViewModel lifetime is the Nav3 entry's `ViewModelStore`.** This is the linchpin that makes the app-scoped back stack safe (see II.4): a ViewModel survives a configuration change as the *same* instance, while the `BackstackController` outlives all of them. + +## I.4 Workers + +WorkManager workers can't be constructed by us directly, so they go through a multibound map and a custom `WorkerFactory`: + +```kotlin +@Inject +class MetroWorkerFactory( + private val factories: Map, ChildWorkerFactory>, +) : WorkerFactory() { + override fun createWorker(appContext, workerClassName, params): ListenableWorker? = + factories.entries.firstOrNull { it.key.java.name == workerClassName }?.value?.create(appContext, params) +} +``` + +A worker contributes an `@AssistedFactory ChildWorkerFactory` keyed with `@WorkerKey(MyWorker::class)` into the map. `:app` exposes `workerFactory` off the graph and installs it in the WorkManager configuration. + +## I.5 Demo mode — the one place we want two implementations + +The app has a demo mode (a fully fake backend for screenshots/onboarding). It is the *only* legitimate reason to have two implementations of the same type and choose between them at runtime: + +```kotlin +fun interface Provider { suspend fun provide(): T } + +interface ProdOrDemoProvider : Provider { + val demoManager: DemoManager + val demoImpl: T + val prodImpl: T + override suspend fun provide(): T = + if (demoManager.isDemoMode().first()) demoImpl else prodImpl +} +``` + +A `ProdOrDemoProvider` is always `@SingleIn(AppScope::class)`. Consumers inject `Provider` and call `.provide()`. + +**Rule:** do not reach for `Provider` to lazily resolve ordinary dependencies. Its sole purpose is the demo/prod swap. If you see `Provider` in a constructor, that `Something` has a demo implementation. + +## I.6 iOS / KMP: the second graph + +The iOS target has its own Metro graph in `:shareddi`: + +```kotlin +@DependencyGraph(AppScope::class) +internal interface IosGraph : ViewModelGraph { + val imageLoader: ImageLoader + @DependencyGraph.Factory + interface Factory { + fun create( + @Provides accessTokenFetcher: AccessTokenFetcher, + @Provides deviceIdFetcher: DeviceIdFetcher, + @Provides featureManager: FeatureManager, + @Provides languageStorage: LanguageStorage, + @Provides appBuildConfig: AppBuildConfig, + ): IosGraph + } +} +``` + +Both graphs target `AppScope`, so the **same `@ContributesBinding`/`@ContributesTo` declarations in shared KMP modules populate both** — that's the whole point of a compile-time, contribution-based graph for KMP. The iOS host provides the platform seams (token fetcher, device id, feature flags, language, build config) via the factory. `metro-viewmodel` is Android-only; iOS resolves its ViewModels through `ViewModelGraph` differently (the iOS Compose UI view controllers wire them directly). + +## I.7 Build wiring + +`hedvig.gradle.plugin` applies Metro to **every** Kotlin module, lazily, after a Kotlin plugin is present (`configureMetro` in `HedvigGradlePlugin.kt`): + +```kotlin +pluginManager.withPlugin(kotlinMultiplatform) { apply(metro) } +pluginManager.withPlugin(kotlinJvm) { apply(metro) } +pluginManager.withPlugin(kotlin) { apply(metro) } +// metro-viewmodel artifacts only on Android modules: +pluginManager.withPlugin("com.android.library") { addMetroViewModelDependencies() } +pluginManager.withPlugin("com.android.application") { addMetroViewModelDependencies() } +``` + +Using `withPlugin` makes us independent of plugin ordering inside each module's `plugins {}` block. The metro-viewmodel runtime is gated on Android plugins so pure-JVM library modules don't drag in Compose. + +**Required flag:** `metro.generateContributionProviders=true` in `gradle.properties`. Without it, contributions aren't materialized into providers and the graph won't compile. + +--- + +# Part II — Navigation 3 and the single back stack + +## II.1 The inversion: we own the back stack + +There is exactly one `NavDisplay`, in `HedvigApp`, and its back stack *is* the controller's list: + +```kotlin +NavDisplay( + backStack = backstackController.entries, // SnapshotStateList + onBack = { backstackController.popBackstack() }, // pops, or finishes the app at the root (see II.3) + entryDecorators = entryDecorators { backstackController.allLiveContentKeys }, + sceneDecoratorStrategies = sceneDecoratorStrategies, + transitionSpec = hedvigTransitionSpec(backstackController, density), + popTransitionSpec = popSpec, + predictivePopTransitionSpec = { popSpec() }, + entryProvider = entryProvider { hedvigEntryProvider(backstack = backstackController, /* … */) }, +) +``` + +`NavDisplay` is a pure renderer of an observable list. There is no `NavController`, no route strings, no `navgraph`/`navdestination`. Mutating `entries` (push/pop) re-renders. + +## II.2 Destinations are keys: `HedvigNavKey` + +```kotlin +interface HedvigNavKey : NavKey +``` + +A destination is a `@Serializable` class or object implementing `HedvigNavKey`. The `@Serializable` is load-bearing — it's how the back stack survives process death (Part III). + +```kotlin +@Serializable +data object InsurancesKey : HedvigNavKey, CrossSellEligibleDestination, TopLevelTabRoot { + override val topLevelTab = TopLevelTab.Insurances +} + +@Serializable +internal data class InsuranceContractDetailKey(val contractId: String) : + HedvigNavKey, DeepLinkAncestry, CrossSellEligibleDestination { + override val owningTab = TopLevelTab.Insurances + override val syntheticParents = emptyList() +} +``` + +### The marker-interface family (in `navigation-common`, KMP `commonMain`) + +This is one of the most important design ideas in the whole migration, so it's worth dwelling on. `:app` needs to reason about destinations — which tab does this belong to? can the cross-sell sheet show here? should we stash the session on logout? — **without depending on the feature module that defines the key** (that would break "features can't depend on features", and `:app` would have to import every feature's internal keys). + +The solution: keys *declare their own capabilities* by implementing small marker interfaces that live in the shared `navigation-common` module. `:app` reasons against the markers, never the concrete key. + +| Marker | Exposes | Meaning | +|---|---|---| +| `TopLevelTabRoot` | `topLevelTab: TopLevelTab` | This key is the root of a bottom-nav tab. Lets the runs model map any tab root back to its tab without naming the feature. | +| `DeepLinkAncestry` | `owningTab`, `syntheticParents: List` | How to rebuild a synthetic back stack when this key is entered alone (e.g. from a notification). | +| `CrossSellEligibleDestination` | — | The cross-sell bottom sheet may appear when this screen is on top. | +| `SuppressesChatPushNotification` | — | A chat push must be suppressed while this screen is shown (the screen already shows the message). | +| `DeliberateLogoutOrigin` | — | Reaching the logged-out state from here is an intentional sign-out — don't stash the session for restore. | + +Note the **same-module invariant** baked into `DeepLinkAncestry.syntheticParents`: a key may only list parents from its *own* feature. The jump *up to a tab* is expressed abstractly via `owningTab`, so no feature ever names another feature's key. This is what keeps the feature-isolation rule intact while still allowing rich synthetic back stacks. + +## II.3 The `Backstack` seam: interface for Presenters, concrete for `:app` + +`navigation-compose` defines a thin interface: + +```kotlin +@Stable +interface Backstack { + val entries: MutableList + fun navigateUp(): Boolean = popBackstack() // default: plain pop; :app overrides it + fun popBackstack(): Boolean = Snapshot.withMutableSnapshot { /* pop if size > 1, else false */ } +} + +fun Backstack.add(key: HedvigNavKey): Boolean = entries.add(key) +inline fun Backstack.popUpTo(inclusive: Boolean) { … } +inline fun Backstack.navigateAndPopUpTo(key, inclusive) { … } +inline fun Backstack.findLastOrNull(): T? = … +inline fun Backstack.removeAllOf() { … } +``` + +`entries` is public and is the single source of truth; the helpers exist so navigation operations are discoverable on a dedicated type rather than drowning in the full `MutableList` API. Features can add their own flow-specific extensions on the same receiver. + +**Both `navigateUp` and `popBackstack` are interface methods, not extensions, precisely so `:app`'s controller can override them.** The defaults are pure list operations (`popBackstack` returns `false` at the root, no side effect) — that keeps the KMP module Android-free and lets the `commonTest` `TestBackstack` exercise them. `BackstackController` overrides `popBackstack` so that a pop at the root **finishes the Activity** (Back/close from the last screen exits the app instead of silently no-opping). `navigateUp`'s override deliberately falls through to the *pure* `super.popBackstack()`, so an Up press at the root stays a no-op and never exits — Up is not Back. A caller that wants a different at-root outcome (e.g. termination-success reached as a lone deep link routes to the Insurances tab) must branch on `entries` itself, because in `:app` the root case no longer returns `false` from `popBackstack` — it finishes. + +**Why a narrow interface for Presenters.** Presenters (Molecule) are long-lived. They get the `Backstack` interface, which exposes navigation verbs but hides the controller's tab-switching / login-stash internals. The concrete `BackstackController` is handed only to `:app` and `HedvigApp`, which legitimately need the full surface. + +## II.4 The central decision: an app-scoped controller, not composition state + +> This is the single most important "why" in the navigation rewrite. Read it carefully before changing anything in `BackstackController`. + +In the old world, navigation state lived in `rememberSerializable` inside `MainActivity.setContent`. That worked for a `NavController` because composables read it directly. But we drive navigation from **Presenters**, which are hosted in ViewModels. + +Here's the lifetime mismatch that breaks the naive approach: + +- A **Metro ViewModel survives a configuration change** as the *same* instance (its `ViewModelStore` outlives Activity recreation). +- **Composition-scoped state does not** — on rotation, the Activity is recreated, `setContent` re-runs, and `rememberSerializable` deserializes the state into a **brand-new object**. + +So if a long-lived Presenter captured a reference to the composition-scoped back stack, that reference would go **stale on the very next rotation** — the Presenter would be mutating a dead copy while the UI rendered a different one. + +**The fix:** hoist navigation state into an **app-scoped Metro singleton** (`BackstackController`), so it outlives every ViewModel and every Activity instance. A Presenter can hold the `Backstack` reference for its whole life and always mutate the live, rendered stack. + +```kotlin +@Stable +internal class BackstackController( + override val entries: SnapshotStateList, + internal val parkedRuns: SnapshotStateMap>, + pendingDeepLinkState: MutableState, + stashedSessionState: MutableState, + var isOwnTask: () -> Boolean = { true }, + var escapeToOwnTask: (List) -> Unit = {}, +) : Backstack { … } + +@ContributesTo(AppScope::class) +internal interface BackstackControllerProviders { + @Provides @SingleIn(AppScope::class) + fun provideBackstackController(): BackstackController = + BackstackController(mutableStateListOf(), mutableStateMapOf(), mutableStateOf(null), mutableStateOf(null)) + + @Provides + fun bindBackstack(controller: BackstackController): Backstack = controller +} +``` + +The four state holders are created once and owned by the singleton, so they survive configuration changes while remaining the live objects the UI renders. The same singleton is exposed two ways: as `BackstackController` to `:app`, and as `Backstack` to feature Presenters. + +**The `isOwnTask` / `escapeToOwnTask` / `finishApp` hooks are Activity-bound and attached in `onCreate`, never in the constructor.** The controller is an app-singleton that outlives any single Activity; capturing an Activity-bound lambda at construction would be the *exact* stale-reference leak we set out to avoid. They default to in-process behaviour (and `finishApp` to a no-op) so unit tests need no Activity. + +## II.5 Registering destinations: `hedvigEntryProvider` and cross-feature lambdas + +Every feature exposes a `fun EntryProviderScope.featureEntries(...)` that registers its screens with `entry { }`. `:app`'s `hedvigEntryProvider` calls all of them, decomposed into readable per-domain sub-builders (`addHomeEntries`, `addInsuranceEntries`, `addProfileEntries`, `addSharedFlowEntries`, …). + +```kotlin +internal fun EntryProviderScope.hedvigEntryProvider( + backstack: BackstackController, scope: CoroutineScope, windowSizeClass: WindowSizeClass, … +) { + val navigateToNewConversation: () -> Unit = { backstack.add(ChatKey(Uuid.randomUUID().toString())) } + val navigateToConnectPayment: () -> Unit = { backstack.add(TrustlyKey) } + // … cross-feature navigation closures derived ONCE here … + + addHomeEntries(backstack, …, navigateToNewConversation, navigateToConnectPayment, …) + addInsuranceEntries(backstack, …, navigateToNewConversation, navigateToMovingFlow, …) + // … +} +``` + +**Why all wiring lives in `:app`.** A feature's `*Key`s live in the feature *impl* module, and the build rule blocks one feature from referencing another's key. So the only place that can legally know "Insurances → start a chat conversation" is `:app`. The pattern: `:app` derives a `navigateToX` lambda once and threads it down to whichever feature entries need it. Features expose *callbacks*, never imports of sibling keys. + +Inside an entry, the ViewModel is resolved with `metroViewModel()` / `assistedMetroViewModel(...)` and the destination composable is rendered. `dropUnlessResumed { }` guards navigation callbacks so a double-tap or a click during a transition can't push twice. + +## II.6 `feature-x-navigation` modules and the enforced carve-out + +Sometimes a feature genuinely needs to navigate to *another* feature's entry point (e.g. anywhere → chat). The keys that are meant to be reachable cross-feature live in a dedicated `feature-{name}-navigation` module containing **only** those public `@Serializable` keys. Everything else — entries, screens, internal keys — stays private in the owning feature. + +The feature-isolation rule has a precise carve-out for exactly these modules (`configureFeatureModuleGuidelines` in `HedvigGradlePlugin.kt`): + +```kotlin +fun String.isFeatureModule() = startsWith("feature-") && !startsWith("feature-flags") +fun String.isFeatureNavigationModule() = isFeatureModule() && endsWith("-navigation") + +// In the resolution strategy: a dependency on another feature module fails the build, +// UNLESS the requested module is a `-navigation` module. +if (requested.name.isFeatureNavigationModule()) return@eachDependency // allowed +require(!requested.name.isFeatureModule()) { "…feature cannot depend on feature…" } +``` + +So: depend on `feature-chat-navigation` to get `ChatKey`; you still cannot depend on `feature-chat`. + +## II.7 Multiple back stacks: the "runs model" + +Each bottom-nav tab needs its own back stack with full state retention. We model this without N separate `NavController`s using a single list plus a parked map. The logic is split between `BackstackController` (mutations) and `TopLevelRunLogic.kt` (pure list functions). + +**Invariant: Home is always the base.** `entries[0]` is always `HomeKey` (when logged in) or `LoginKey` (when logged out). Home's "run" (Home plus any drill-down screens) is always present at the bottom of `entries`. A side tab, when active, sits *on top of* Home's run. + +**Parking.** When you switch away from a side tab, its run (the side-tab root plus its drill-downs) is lifted out of `entries` into `parkedRuns: SnapshotStateMap>`. When you switch back, it's restored. Home is never parked — it stays in the rendered stack. + +```kotlin +fun selectTopLevel(topLevelTab: TopLevelTab) = Snapshot.withMutableSnapshot { + if (topLevelTab == currentTopLevel) { // re-tap current tab → pop its run to root + entries.replaceWith(popTopRunToStart(entries)); return@withMutableSnapshot + } + val leavingSideTab = nearestTopLevelTab(entries)?.takeIf { it != TopLevelTab.Home } + val homeRun = collapseToHome(entries) + if (leavingSideTab != null) parkedRuns[leavingSideTab] = activeSideRun(entries) + val restored = if (topLevelTab == TopLevelTab.Home) homeRun + else homeRun + (parkedRuns.remove(topLevelTab) ?: listOf(topLevelTab.startDestination)) + entries.replaceWith(restored) +} +``` + +The pure helpers (`collapseToHome`, `activeSideRun`, `popTopRunToStart`, `nearestTopLevelTab`) operate on a plain `List` and are trivially unit-testable. + +## II.8 Keeping parked state alive: retained decorators and `allLiveContentKeys` + +A parked tab's run is no longer in `entries`, so by default Nav3 would dispose its saved state and ViewModels. We don't want that — switching back to a tab should restore it exactly. Two custom `NavEntryDecorator`s solve this: + +```kotlin +@Composable +fun entryDecorators(retainedContentKeys: () -> Set): List> = listOf( + rememberRetainedSaveableStateHolderNavEntryDecorator(retainedContentKeys), + rememberRetainedViewModelStoreNavEntryDecorator(retainedContentKeys), +) +``` + +They consult `retainedContentKeys` — wired to `backstackController.allLiveContentKeys` — when deciding whether to dispose a key's state on pop: + +```kotlin +val allLiveContentKeys: Set get() = buildSet { + entries.forEach { add(it.toString()) } + parkedRuns.values.forEach { run -> run.forEach { add(it.toString()) } } +} +``` + +A key that merely *moved into* `parkedRuns` is still "live", so its saved state and ViewModel are kept. A key actually removed (popped from both) is disposed. Note `StashedSession` (Part IV) is deliberately **excluded** from this set, so a stashed logged-out session has all its per-entry state disposed while it waits. + +## II.9 Chrome (the nav bar/rail) as a Scene decorator + +The persistent bottom bar / side rail is rendered *inside* the scene via a `SceneDecoratorStrategy` (`NavSuiteSceneDecorator`), not by wrapping `NavDisplay` in an outer `Row/Column { chrome; content }`. This is the purpose-built Nav3 way to add persistent chrome that rides inside the top-level `AnimatedContent`, so it stays visually static while content transitions. + +Chrome is opt-in per destination: only scenes whose metadata carries `NavSuiteSceneDecoratorStrategy.showNavBar()` (the tab roots plus the handful of deeper screens that keep the bar) get wrapped; everything else is full-screen. + +A lone deep link is a special case. The runs-model invariant (`entries[0] == HomeKey`) is briefly broken when a deep link stands alone, so showing the rail would expose that. The controller classifies the situation and the decorator obeys: + +```kotlin +enum class LoneDeepLinkChrome { ShowSuite, ShowUpBar, ShowNothing } + +val loneDeepLinkChrome: LoneDeepLinkChrome get() { + val first = entries.firstOrNull() + val isAlone = entries.size == 1 && first !is HomeKey && first !is LoginKey + return when { + !isAlone -> LoneDeepLinkChrome.ShowSuite // normal: rail + first?.topLevelTabOrNull() != null -> LoneDeepLinkChrome.ShowUpBar // lone tab root: decorator supplies Up + else -> LoneDeepLinkChrome.ShowNothing // deep screen renders its own Up + } +} +``` + +## II.10 Transitions: fade between tabs, slide within one + +`hedvigTransitionSpec` decides fade-through vs slide by asking the controller which tab the outgoing and incoming top keys belong to: + +```kotlin +val fromTab = backstack.owningTopLevelTabForContentKey(initialState.entries.lastOrNull()?.contentKey) +val toTab = backstack.owningTopLevelTabForContentKey(targetState.entries.lastOrNull()?.contentKey) +if (shouldFadeThrough(fromTab, toTab)) /* fade */ else /* slide */ +``` + +`shouldFadeThrough(from, to)` is true only when both are non-null and differ — a tab change. A transition to/from a tab-less screen (e.g. Login) is never a tab change. + +**Why ownership is positional, not per-key.** A screen's owning tab depends on *which run it sits in*, which can't be read from a single key in isolation (restoring a parked side-tab run lands on a deep screen whose key alone doesn't name a tab). `owningTopLevelTabForContentKey` resolves it from the full rendered stack plus all parked runs, and memoizes into `owningTabByContentKey`. That map **accumulates and is never cleared**: a just-popped key keeps its last-known owner so the *outgoing* scene of the pop can still be classified. A key type only ever lives in one tab's run, so a retained owner can't go stale. + +--- + +# Part III — State persistence and the Activity seam + +## III.1 The problem + +An in-memory singleton is wiped when the process dies. So the app-scoped `BackstackController` needs help to survive **process death** (but not configuration changes — on a config change the singleton is still alive and must be reused untouched). + +## III.2 `NavigationStateBridge` — the single seam + +All crossing of the "Activity lifecycle ↔ app-scoped singleton" boundary is funneled through one stateless object, `NavigationStateBridge`, so the decision about what the stack should be at launch is made in exactly one place: + +```kotlin +fun restoreAndPersist( + backstackController: BackstackController, + savedStateRegistry: SavedStateRegistry, + intent: Intent, + isColdStart: Boolean, // == (savedInstanceState == null) + serializersModules: Set, +) { + val config = SavedStateConfiguration { serializersModule = serializersModules.merge() } + + // Seeding precedence, top to bottom: + val handoff = if (isColdStart) readEscapeToOwnTaskHandoff(intent, serializersModules) else null + if (!handoff.isNullOrEmpty()) { + backstackController.reseed(handoff) // 1. explicit re-root + } else { + savedStateRegistry.consumeRestoredStateForKey(NAV_STATE_REGISTRY_KEY) // 2. process-death restore + ?.let { decodeFromSavedState(NavStateSnapshot.serializer(), it, config) } + ?.let { backstackController.restoreFromSavedState(it.entries, it.parkedRuns, it.pendingDeepLink, it.stashedSession) } + backstackController.seedIfEmpty(listOf(LoginKey)) // 3. guarantee a root + } + + // Persist on save — provider reads whatever the singleton holds at save time: + savedStateRegistry.registerSavedStateProvider(NAV_STATE_REGISTRY_KEY) { + encodeToSavedState(NavStateSnapshot.serializer(), NavStateSnapshot( + backstackController.entries.toList(), backstackController.parkedRuns.toMap(), + backstackController.pendingDeepLink, backstackController.stashedSession), config) + } +} +``` + +Key points: + +- **`restoreFromSavedState` and `seedIfEmpty` are both no-ops when the live state is already populated.** On a configuration change the singleton is alive and populated, so the serialized snapshot is ignored and the *live* state always wins. Only on a genuine cold start (empty singleton) does the snapshot re-hydrate it. +- The whole hoisted state is captured in `NavStateSnapshot` (entries + parkedRuns + pendingDeepLink + stashedSession), mirroring the four holders. +- The persist provider serializes at save time, so a navigation driven by a Presenter milliseconds before the process is killed is captured. + +## III.3 The KSP processor: polymorphic serializers without boilerplate + +The back stack is `List<@Polymorphic HedvigNavKey>`. To (de)serialize it, kotlinx.serialization needs every concrete subtype registered polymorphically. The subtypes live across ~25 feature modules, and we don't use JVM reflection (it doesn't exist on iOS). Hand-writing a `SerializersModule { polymorphic(HedvigNavKey::class) { subclass(...) } }` per module would be error-prone boilerplate that silently rots when someone forgets to add a new key. + +So `:navigation-keys-processor` (a KSP `SymbolProcessor`) generates it. For every concrete `@Serializable` type implementing `HedvigNavKey` in a module, it emits one provider interface per package: + +```kotlin +@ContributesTo(AppScope::class) +interface FeatureFoo…GeneratedNavKeySerializersModuleProvider { + @Provides @IntoSet + fun provide…NavKeySerializersModule(): SerializersModule = SerializersModule { + polymorphic(HedvigNavKey::class) { + subclass(FooKey::class) + subclass(BarKey::class) + } + } +} +``` + +Metro merges every contributed module into the multibound `Set`; `:app` folds it with `merge()` and feeds it into the `SavedStateConfiguration` and the handoff `Json`. + +**The unique-name problem and its solution.** Every generated interface is merged into one graph, so its name and provide-method name must be globally unique or Metro sees a duplicate-type / same-signature diamond. The package alone isn't enough — a feature and its `feature-x-navigation` sister legitimately share a package (`.navigation`). The processor folds the **set of key FQNs in this compilation** into the name via a dependency-free **FNV-1a 32-bit hash**: + +```kotlin +val uniqueToken = "${packageName.toUniqueToken()}_${keyFqns.joinToString("\n").stableHash()}" +``` + +A nav-key class is declared in exactly one module and KSP only processes the current compilation's declarations, so this module's key set is globally unique by construction. The hash is deterministic across machines/JVMs (no salt), and changes only when the module's key set changes — which already regenerates the file. + +**Opt-in via `hedvig { navKeys() }`.** A module wires the processor by calling `navKeys()` in its `hedvig {}` block (`NavKeysHandler` attaches the KSP dep to `ksp` for plain Android or `kspAndroid` for KMP — the Android target's KSP sees `commonMain` symbols too, so one pass covers main/androidMain/commonMain and avoids double-emission). + +> **The single most common new-key bug:** you add a `@Serializable HedvigNavKey` but the module is missing `navKeys()`. It compiles and runs fine until process-death restore, then crashes with a missing polymorphic serializer. `ExhaustiveBackStackSerializationTest` exists to catch exactly this. + +--- + +# Part IV — Sessions, auth, and the splash gate + +## IV.1 `SessionReconciler` + +Auth↔back-stack reconciliation used to be loose effects in `HedvigApp`. It now lives in one app-scoped class with two narrowly-scoped jobs: + +```kotlin +@SingleIn(AppScope::class) @Inject +internal class SessionReconciler( + private val backstackController: BackstackController, + private val authTokenService: AuthTokenService, + private val demoManager: DemoManager, + private val memberIdService: MemberIdService, +) { + val isReady: StateFlow // gates the splash + + suspend fun reconcile() { … } // 1. cold-start start-scene resolution + suspend fun observeForcedLogout(lifecycle: Lifecycle) { … } // 2. runtime root honesty +} +``` + +**1. Start-scene resolution gates the splash.** `MainActivity` keeps the splash up while `!sessionReconciler.isReady.value`. `reconcile()` resolves whether the first frame should be Home (logged in / demo) or Login *before* the splash is dismissed, so we never flash the seeded Login root and then jump to Home. It uses Arrow `raceN` of "auth says LoggedIn" vs "demo mode on", and only mutates the root if it disagrees with the already-restored stack — so a process-death restore that already reflects the previous session is left untouched. + +**2. Forced logout while running.** `observeForcedLogout` is lifecycle-gated to `STARTED`: it tracks the latest member id and logs out if we leave demo mode without valid tokens. + +The reconciler is deliberately narrow — *only* the auth-driven root. Deep links, notifications, and everything else live in their own observers. The `BackstackController` is injected (app-scoped); only the `Lifecycle` is passed per-call by the composition, keeping the reconciler free of any Android/Compose lifetime. + +## IV.2 Login/logout stashing + +Logout doesn't just drop to Login — it can **stash** the session so a same-member re-login restores their place: + +```kotlin +fun setLoggedOut(memberId: String?) = Snapshot.withMutableSnapshot { + val isDeliberateLogout = entries.lastOrNull() is DeliberateLogoutOrigin + stashedSession = if (memberId != null && !isDeliberateLogout) + StashedSession(memberId, entries.toList(), parkedRuns.toMap()) else null + parkedRuns.clear(); entries.replaceWith(listOf(LoginKey)) +} + +fun setLoggedIn(memberId: String?) = Snapshot.withMutableSnapshot { + val pending = pendingDeepLink; pendingDeepLink = null + val stash = stashedSession?.takeIf { memberId != null && it.memberId == memberId }; stashedSession = null + parkedRuns.clear() + when { + pending != null -> entries.replaceWith(listOf(pending)) // deep link lands alone + stash != null -> { parkedRuns.putAll(stash.parkedRuns); entries.replaceWith(stash.entries) } + else -> entries.replaceWith(listOf(HomeKey)) // fresh Home + } +} +``` + +Rules encoded here: + +- A stash is tagged with the `memberId` (JWT `sub`). It's only restored if the next login is the **same member**. Otherwise it's dropped — a stash can never bleed into a different member's session. +- `memberId == null` (demo / unknown identity) stashes nothing — that session can never be safely restored. +- A `DeliberateLogoutOrigin` top screen (e.g. Profile → Log out) means "log me out now"; we don't stash even with a known member. +- `StashedSession` is **excluded from `allLiveContentKeys`**, so the retained decorators dispose every per-entry state (ViewModels, saved state) while it waits. On restore, history comes back but state is freshly recreated. +- A `pendingDeepLink` takes precedence over a stash and lands **alone** (re-enabling the runs model on the next Up via the synthetic stack). + +--- + +# Part V — Deep links and task management + +## V.1 Matching + +Each feature builds `DeepLinkMatcher`s from its `HedvigDeepLinkContainer` URI patterns and contributes a `DeepLinkMatcherProvider` (`@ContributesIntoSet`). `ApplicationMetroProviders` folds the multibound set into one `HedvigDeepLinkMatcher`, which returns the highest-priority matching `HedvigNavKey` (or null → open in a browser). A matcher whose key has a required argument *throws* when the arg is absent rather than returning null, so the aggregator treats any throw as a non-match. + +## V.2 From Intent to navigation + +`MainActivity` forwards `ACTION_VIEW` intents as raw URI strings down an unlimited `deepLinkChannel`. `HedvigApp` collects them and routes each through the matcher once the member is logged in. This replaces Nav2's automatic launch-intent deep-link handling on the (now removed) `NavController`. + +```kotlin +fun navigateToDeepLink(key: HedvigNavKey) { + if (!isLoggedIn) { pendingDeepLink = key; return } // held; landed by setLoggedIn + Snapshot.withMutableSnapshot { entries.remove(key); entries.add(key) } // dedup + append (Nav2 parity) +} +``` + +Logged in, a deep link **joins the current task** (dedup then append). Logged out, it's held as `pendingDeepLink` (persisted across rotation / process death — e.g. mid-OTP) and landed alone after login. + +## V.3 Synthetic back stacks and task-aware Up + +When a deep link is entered alone, Back/Up must behave as if the user had navigated there inside the app. `syntheticStackFor` rebuilds the ancestry, reusing the abstract `DeepLinkAncestry` markers so no feature names another's key: + +```kotlin +internal fun syntheticStackFor(key: HedvigNavKey): List { + val ancestry = key as? DeepLinkAncestry + val tab = ancestry?.owningTab ?: TopLevelTab.Home + return buildList { + add(HomeKey) + if (tab != TopLevelTab.Home) add(tab.startDestination) + addAll(ancestry?.syntheticParents.orEmpty()) + add(key) + }.distinct() +} +``` + +`navigateUp` is task-aware: + +```kotlin +override fun navigateUp(): Boolean { + val top = entries.lastOrNull() ?: return false + val synthetic = syntheticStackFor(top) + if (entries.size == 1 && synthetic.size > 1) { // lone deep link with real ancestry + val parentStack = synthetic.dropLast(1) + if (isOwnTask()) entries.replaceWith(parentStack) // materialize in place + else escapeToOwnTask(parentStack) // re-root into our own task + return true + } + return super.popBackstack() // everywhere else: PURE pop (no app-finish) +} + +override fun popBackstack(): Boolean { // Back / close + val popped = super.popBackstack() + if (!popped) finishApp() // at the root → exit the app + return popped +} +``` + +`navigateUp` falls through to `super.popBackstack()` (the pure interface default) on purpose: an Up press that can't go up is a no-op, whereas a Back press at the root exits the app. The `finishApp` lambda is an Activity-bound hook attached in `onCreate` alongside `isOwnTask` / `escapeToOwnTask` (same stale-reference reasoning), and is a no-op by default so unit tests never try to finish. + +## V.4 Escaping a foreign task + +If we were launched into the *caller's* task by an external deep link (`isTaskRoot == false`, so `isOwnTask()` is false), pressing Up shouldn't rebuild our ancestry hosted under the foreign app. Instead we relaunch in our own task: + +```kotlin +fun escapeToOwnTask(activity, parentStack, serializersModules) { + val relaunch = Intent(activity, MainActivity::class.java).apply { + addFlags(FLAG_ACTIVITY_NEW_TASK or FLAG_ACTIVITY_CLEAR_TASK) + putExtra(EXTRA_RESTORE_STACK, json(serializersModules).encodeToString(handoffSerializer, parentStack)) + } + activity.finish(); activity.startActivity(relaunch) +} +``` + +The fresh instance reads the ancestry back in `restoreAndPersist` via the **same extra key and codec**, so the write/read contract can't drift. `reseed` clears parked runs / pending deep link / stash, because an escape relaunches with `CLEAR_TASK` while the singleton may survive — old-task state must not bleed into the new root. + +--- + +# Part VI — Invariants, decisions, and tests + +## VI.1 Invariants you must not break + +1. **`entries` is never empty.** There is always at least a root (`LoginKey` or `HomeKey`). The pure pop refuses to remove the last entry; `BackstackController.popBackstack` finishes the Activity in that case instead, so Back/close from the root exits the app rather than emptying the stack. +2. **`entries[0]` is `HomeKey` (logged in) or `LoginKey` (logged out)** — except the transient lone-deep-link state, which is exactly why `loneDeepLinkChrome` exists. +3. **Home is never parked.** Only side tabs go into `parkedRuns`. +4. **Every `@Serializable HedvigNavKey` must be registered for polymorphic serialization** → its module needs `navKeys()`. +5. **A `DeepLinkAncestry.syntheticParents` list contains only the key's own feature's keys.** Cross-tab jumps go through `owningTab`. +6. **Features never import another feature's keys.** Cross-feature navigation is a `navigateToX` lambda from `:app`, or a dependency on a `-navigation` module. +7. **No Activity-bound reference is captured in the `BackstackController` constructor.** Activity hooks are attached in `onCreate` and re-attached on recreation. +8. **GraphQL `octopus.*` types never appear in public APIs** (see CLAUDE.md data-layer rule). + +## VI.2 Tests that guard the architecture + +- **`ExhaustiveBackStackSerializationTest`** (`:app`) — round-trips every `HedvigNavKey` through the merged serializers module. This is the safety net for invariant #4: forget `navKeys()` on a module and this test fails instead of users hitting a process-death crash. +- **`BackstackTest`** (`navigation-compose`) — the `Backstack` helper extensions. +- **`HedvigDeepLinkMatcherTest`** — matching/priority/throw-as-non-match behaviour. +- **`HedvigNavKeySavedStateTest`** (`navigation-common`) — key saved-state behaviour. +- The `TopLevelRunLogic` helpers are pure list functions, unit-testable without a controller. + +## VI.3 Decisions, restated + +| Decision | Why | Rejected alternative | +|---|---|---| +| Single `AppScope`, no subscoping | Screen lifetime is owned by Nav3 ViewModelStores, not DI | Per-screen/per-feature DI scopes (redundant) | +| App-scoped `BackstackController` | Long-lived Presenters need a stable reference; composition state goes stale on rotation | `rememberSerializable` in `setContent` | +| Markers in `navigation-common` | `:app` reasons about keys without depending on features | `:app` importing every feature's keys | +| `-navigation` modules | Allow targeted cross-feature navigation while keeping isolation | Relaxing the feature-isolation rule globally | +| Runs model (one list + parked map) | Multiple back stacks with retention, all serializable as one snapshot | N `NavController`s / N back stacks | +| KSP-generated serializer registrations | No hand-written boilerplate, no reflection (iOS-safe), can't silently rot | Per-module hand-written `SerializersModule` | +| `NavigationStateBridge` as the only seam | One place owns launch-time stack decisions | Restore logic spread across `MainActivity` | +| Chrome via `SceneDecoratorStrategy` | Bar rides inside `AnimatedContent`, stays static during transitions | Outer `Row/Column { chrome; content }` | + +--- + +# Part VII — How to do common things + +**Add a screen.** Define `@Serializable FooKey : HedvigNavKey` (add markers as needed) → register `entry { }` in the feature's entries function → resolve the VM with `metroViewModel()`/`assistedMetroViewModel(...)` → ensure the module has `navKeys()`. + +**Navigate to it from the same feature.** `backstack.add(FooKey(...))` (or `popUpTo`, `navigateAndPopUpTo`, …). + +**Navigate to it from another feature.** Put `FooKey` in `feature-foo-navigation`; in `:app`, derive a `navigateToFoo` lambda and thread it to the calling feature's entries function. The calling feature exposes a callback, never imports `FooKey`. + +**Make it deep-linkable.** Add patterns to the feature's `HedvigDeepLinkContainer`, build matchers via `uriDeepLinkMatchers`, contribute a `DeepLinkMatcherProvider` (`@ContributesIntoSet`). If it can be entered alone, implement `DeepLinkAncestry`. + +**Make it a tab root.** Implement `TopLevelTabRoot`; add the tab to `TopLevelTab`; map its start destination in `startDestination`. + +**Add a binding.** Annotate the impl `@ContributesBinding(AppScope::class)` + `@Inject` constructor. Framework/configured object → `@Provides` in a `@ContributesTo(AppScope::class)` interface. Singleton → add `@SingleIn(AppScope::class)`. + +**Add a ViewModel.** No args: `@Inject` + `@ContributesIntoMap(AppScope::class)` + `@ViewModelKey(Vm::class)`. With nav args: the `@AssistedInject` + `@AssistedFactory @ManualViewModelAssistedFactoryKey @ContributesIntoMap` pattern in I.3. + +**Add a demo-mode-aware dependency.** Make a `ProdOrDemoProvider` (`@SingleIn`), inject `Provider`, call `.provide()`. + +**Add a worker.** `@AssistedFactory ChildWorkerFactory` keyed `@WorkerKey(W::class)`, `@ContributesIntoMap`. + +--- + +## Glossary + +- **AppScope** — the single Metro scope for the whole app graph. A bare marker class. +- **Contribution** — a binding declared in any module (`@ContributesBinding`/`@ContributesTo`/`@ContributesIntoSet`/`@ContributesIntoMap`) merged into the graph at compile time. +- **HedvigNavKey** — `interface HedvigNavKey : NavKey`; a serializable destination identity. +- **Backstack** — the narrow interface Presenters use; backed by the concrete `BackstackController`. +- **BackstackController** — app-scoped singleton owning `entries`, `parkedRuns`, `pendingDeepLink`, `stashedSession`, plus tab/login/deep-link logic. +- **Run** — a tab's slice of the back stack: its root key plus drill-down screens. +- **Parked run** — a side tab's run lifted out of `entries` into `parkedRuns` while another tab is active. +- **NavigationStateBridge** — the stateless object that seeds/restores/persists nav state across the Activity↔singleton boundary and performs the escape-to-own-task handoff. +- **SessionReconciler** — app-scoped; resolves the start scene (gating the splash) and enforces forced logout. +- **StashedSession** — a logged-out session held for same-member restore; excluded from the live-content set. +- **navKeys()** — the `hedvig {}` DSL call that wires the KSP processor generating serializer registrations. +- **metroViewModel / assistedMetroViewModel** — composable helpers resolving a ViewModel through `AppViewModelFactory` via `LocalMetroViewModelFactory`. +- **Provider / ProdOrDemoProvider** — the demo/prod implementation swap. + +--- + +*If something here disagrees with the code, the code wins — update this doc. The point of writing it down was to make the reasoning survivable; keep it alive.* diff --git a/docs/superpowers/plans/2026-06-04-appstate-backstackcontroller-split-and-nav-markers.md b/docs/superpowers/plans/2026-06-04-appstate-backstackcontroller-split-and-nav-markers.md deleted file mode 100644 index 68a069dec7..0000000000 --- a/docs/superpowers/plans/2026-06-04-appstate-backstackcontroller-split-and-nav-markers.md +++ /dev/null @@ -1,1391 +0,0 @@ -# HedvigAppState / BackstackController Split & Navigation Capability Markers Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Clean up the messy seam between `HedvigAppState` (app-shell state, feature-aware) and `BackstackController` (pure navigation state machine), replace three centralized destination-classification lists with per-key marker interfaces, reinstate the lost "deliberate logout from Profile discards session" behavior, and replace the global-mutable `CurrentDestinationInMemoryStorage` with a DI-injected `StateFlow`-backed holder. - -**Architecture:** `BackstackController` owns all pure-navigation reads/writes (entries, parked runs, logged-in detection, top-level selection, login/logout transitions). `HedvigAppState` keeps only feature-knowledge-layered state (navigation suite type, top-level graph set, payments badge, force-update gate, theme, cross-sell eligibility) and stops re-exposing controller methods as forwarders. Destination classification (cross-sell eligibility, chat-notification suppression, deliberate-logout origin) moves from centralized `List>` registries into marker interfaces declared in `navigation-common` (the one module every `HedvigNavKey` already depends on — features can't depend on features). The current destination is published through a `@SingleIn(AppScope::class)` `CurrentDestinationHolder` exposing a `StateFlow`, written by a dedicated `ReportCurrentDestinationEffect` and read by `ChatNotificationSender` via constructor injection. - -**Tech Stack:** Kotlin, Jetpack Navigation 3, Jetpack Compose (Snapshot state), Metro DI (`@SingleIn`, `@Inject`, `@Provides`, `@IntoSet`), Kotlin Multiplatform (`navigation-common` commonMain), kotlinx.coroutines `StateFlow`, assertk + JUnit. - ---- - -## File Structure - -**Created:** -- `app/app/src/main/kotlin/com/hedvig/android/app/navigation/CurrentDestinationHolder.kt` — `@SingleIn(AppScope::class)` holder exposing `StateFlow`; the single source of "what is on top right now" for non-Composable consumers. - -**Modified:** -- `app/navigation/navigation-common/src/commonMain/kotlin/com/hedvig/android/navigation/common/HedvigNavKey.kt` — add three marker interfaces. -- `app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/navigation/HomeDestinations.kt` — `HomeKey` implements two markers; delete `homeCrossSellBottomSheetPermittingDestinations`. -- `app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/navigation/InsurancesNavigation.kt` — `InsurancesKey` + `InsuranceContractDetailKey` implement cross-sell marker; delete `insurancesCrossSellBottomSheetPermittingDestinations`. -- `app/feature/feature-travel-certificate/src/main/kotlin/com/hedvig/android/feature/travelcertificate/navigation/TravelCertificateDestination.kt` — `TravelCertificateKey` implements cross-sell marker; delete `travelCertificateCrossSellBottomSheetPermittingDestinations`. -- `app/feature/feature-help-center/src/commonMain/kotlin/com/hedvig/android/feature/help/center/navigation/HelpCenterDestination.kt` — `HelpCenterKey` + `HelpCenterTopicKey` + `HelpCenterQuestionKey` implement cross-sell marker; delete `helpCenterCrossSellBottomSheetPermittingDestinations`. -- `app/feature/feature-chat/src/main/kotlin/com/hedvig/android/feature/chat/navigation/ChatDestination.kt` — `ChatKey` + `InboxKey` implement chat-suppression marker. -- `app/feature/feature-claim-details/src/main/kotlin/com/hedvig/android/feature/claim/details/navigation/ClaimDetailDestinations.kt` — `ClaimDetailsKey` implements chat-suppression marker. -- `app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/navigation/ProfileDestinations.kt` — `ProfileKey` implements deliberate-logout marker; delete dead `destinationToExcludeFromSavingState`. -- `app/app/src/main/kotlin/com/hedvig/android/app/notification/senders/ChatNotificationSender.kt` — use marker check; remove `CurrentDestinationInMemoryStorage`; read injected holder. -- `app/app/src/main/kotlin/com/hedvig/android/app/di/ApplicationMetroProviders.kt` — inject `CurrentDestinationHolder` into `provideChatNotificationSender`. -- `app/app/src/main/kotlin/com/hedvig/android/app/navigation/BackstackController.kt` — `setLoggedOut` honors `DeliberateLogoutOrigin`. -- `app/app/src/test/kotlin/com/hedvig/android/app/navigation/BackstackControllerTest.kt` — adjust stash test; add deliberate-logout test. -- `app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigAppState.kt` — cross-sell marker check; remove forwarders; remove the destination-mirroring `LaunchedEffect`. -- `app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigNavHost.kt` — drop `hedvigAppState` param; take `backstackController` + `windowSizeClass` directly. -- `app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigAppUi.kt` — pass `backstackController` + `windowSizeClass` to `HedvigNavHost`; read top-level via controller. -- `app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigApp.kt` — effects call controller directly; add `ReportCurrentDestinationEffect`; thread `currentDestinationHolder`. -- `app/app/src/main/kotlin/com/hedvig/android/app/MainActivity.kt` — inject `CurrentDestinationHolder`, pass to `HedvigApp`. - ---- - -## Notes for the implementing engineer - -- **This repo is highly modular.** A change in `navigation-common` (Task 1) recompiles every dependent module. After the marker interfaces exist, each feature key change (Tasks 2–4) is local to that feature module. -- **Verification commands:** `./gradlew :app:testDebugUnitTest` compiles the `:app` module **and its entire dependency graph** (all the feature modules touched here) and runs `BackstackControllerTest`. That is the single best gate for almost every task. Run `./gradlew ktlintFormat` before each commit and `./gradlew ktlintCheck` to confirm. -- **Gradle exit-code trap (known issue):** do NOT pipe gradle through `tail`/`grep` to read results — a pipe returns the pipe's exit code, not gradle's, so a `BUILD FAILED` can look like success. Run the gradle command bare and read its own `BUILD SUCCESSFUL` / `BUILD FAILED` line and exit status. -- **ktlint flags unused imports.** Every task that removes the last use of an import MUST also remove that import, or `ktlintCheck` fails. Exact import removals are spelled out per task. -- Commit after each task. Stay on the current branch `eng/metro-nav3-pr2-nav2-to-nav3`. Do NOT push or open a PR unless explicitly asked. - ---- - -### Task 1: Add the three capability marker interfaces to `navigation-common` - -**Files:** -- Modify: `app/navigation/navigation-common/src/commonMain/kotlin/com/hedvig/android/navigation/common/HedvigNavKey.kt` - -- [ ] **Step 1: Add the marker interfaces** - -Replace the entire file contents with: - -```kotlin -package com.hedvig.android.navigation.common - -import androidx.navigation3.runtime.NavKey -import kotlin.reflect.KType - -interface HedvigNavKey : NavKey - -interface NavKeyTypeAware { - val typeList: List -} - -/** - * A destination on which the cross-sell bottom sheet is allowed to appear after a member finishes a - * flow (moving, edit co-insured, add/upgrade addon, change tier). Implemented by the screens a member - * lands on at the end of those flows. Replaces the old per-feature - * `xxxCrossSellBottomSheetPermittingDestinations` lists. - */ -interface CrossSellEligibleDestination - -/** - * A destination where an incoming chat push notification must be suppressed (the in-app screen shows - * the new message itself). Replaces `listOfDestinationsWhichShouldNotShowChatNotification`. - */ -interface SuppressesChatPushNotification - -/** - * A destination from which reaching the logged-out state is treated as a deliberate "log me out now" - * action, so the session is discarded rather than stashed for a same-member restore. Restoring the - * nav back to this screen after a fresh login would be wrong. Replaces the dead - * `destinationToExcludeFromSavingState`. - */ -interface DeliberateLogoutOrigin -``` - -- [ ] **Step 2: Verify it compiles** - -Run: `./gradlew :navigation-common:compileKotlinMetadata` -Expected: `BUILD SUCCESSFUL`. (If this task path is unavailable in the multiplatform setup, fall back to `./gradlew :app:compileDebugKotlin` which transitively compiles `navigation-common`.) - -- [ ] **Step 3: ktlint** - -Run: `./gradlew ktlintFormat && ./gradlew ktlintCheck` -Expected: `BUILD SUCCESSFUL`. - -- [ ] **Step 4: Commit** - -```bash -git add app/navigation/navigation-common/src/commonMain/kotlin/com/hedvig/android/navigation/common/HedvigNavKey.kt -git commit -m "$(cat <<'EOF' -feat(nav): add capability marker interfaces in navigation-common - -Adds CrossSellEligibleDestination, SuppressesChatPushNotification and -DeliberateLogoutOrigin markers to replace centralized destination -classification lists. -EOF -)" -``` - ---- - -### Task 2: Convert cross-sell eligibility to `CrossSellEligibleDestination` - -**Files:** -- Modify: `app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/navigation/HomeDestinations.kt` -- Modify: `app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/navigation/InsurancesNavigation.kt` -- Modify: `app/feature/feature-travel-certificate/src/main/kotlin/com/hedvig/android/feature/travelcertificate/navigation/TravelCertificateDestination.kt` -- Modify: `app/feature/feature-help-center/src/commonMain/kotlin/com/hedvig/android/feature/help/center/navigation/HelpCenterDestination.kt` -- Modify: `app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigAppState.kt` - -- [ ] **Step 1: Mark `HomeKey` and delete the home list** - -In `HomeDestinations.kt`, change the `HomeKey` declaration and delete the trailing val. Final file: - -```kotlin -package com.hedvig.android.feature.home.home.navigation - -import com.hedvig.android.navigation.common.CrossSellEligibleDestination -import com.hedvig.android.navigation.common.HedvigNavKey -import com.hedvig.android.navigation.common.NavKeyTypeAware -import com.hedvig.android.ui.emergency.FirstVetSection -import kotlin.reflect.KType -import kotlin.reflect.typeOf -import kotlinx.serialization.Serializable - -@Serializable -data object HomeKey : HedvigNavKey, CrossSellEligibleDestination - -@Serializable -internal data class FirstVetKey(val sections: List) : HedvigNavKey { - companion object : NavKeyTypeAware { - override val typeList: List = listOf(typeOf>()) - } -} -``` - -Note: the `KClass` import is removed (the deleted val was its only use). - -- [ ] **Step 2: Mark insurances keys and delete the insurances list** - -In `InsurancesNavigation.kt`, final file: - -```kotlin -package com.hedvig.android.feature.insurances.navigation - -import com.hedvig.android.navigation.common.CrossSellEligibleDestination -import com.hedvig.android.navigation.common.HedvigNavKey -import com.hedvig.android.navigation.core.DeepLinkAncestry -import com.hedvig.android.navigation.core.TopLevelGraph -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data object InsurancesKey : HedvigNavKey, CrossSellEligibleDestination - -@Serializable -internal data class InsuranceContractDetailKey( - /** Must match the name of the param inside [com.hedvig.android.navigation.core.HedvigDeepLinkContainer.contract] */ - @SerialName("contractId") - val contractId: String, -) : HedvigNavKey, DeepLinkAncestry, CrossSellEligibleDestination { - override val owningTab = TopLevelGraph.Insurances - override val syntheticParents = emptyList() -} - -@Serializable -internal data object TerminatedInsurancesKey : HedvigNavKey -``` - -Note: the `kotlin.reflect.KClass` import is removed. - -- [ ] **Step 3: Mark `TravelCertificateKey` and delete the travel-certificate list** - -In `TravelCertificateDestination.kt`: change line 12-13 and delete lines 48-50. Apply: - -```kotlin -@Serializable -data object TravelCertificateKey : HedvigNavKey, CrossSellEligibleDestination -``` - -Delete: - -```kotlin -val travelCertificateCrossSellBottomSheetPermittingDestinations: List> = listOf( - TravelCertificateKey::class, -) -``` - -Then remove the now-unused `import kotlin.reflect.KClass` and add `import com.hedvig.android.navigation.common.CrossSellEligibleDestination`. (`kotlin.reflect.KType` and `kotlin.reflect.typeOf` stay — still used by `TravelCertificateTravellersInputKey` / `ShowCertificateKey`.) - -- [ ] **Step 4: Mark help-center keys and delete the help-center list** - -In `HelpCenterDestination.kt`: add the marker to the three keys and delete the val. Apply these three edits: - -```kotlin -@Serializable -data object HelpCenterKey : HedvigNavKey, CrossSellEligibleDestination -``` - -```kotlin -@Serializable -internal data class HelpCenterTopicKey( - /** Must match the name of the param inside [com.hedvig.android.navigation.core.HedvigDeepLinkContainer] */ - @SerialName("id") - val topicId: String = "", -) : HedvigNavKey, CrossSellEligibleDestination -``` - -```kotlin -@Serializable -internal data class HelpCenterQuestionKey( - /** Must match the name of the param inside [com.hedvig.android.navigation.core.HedvigDeepLinkContainer] */ - @SerialName("id") - val questionId: String = "", -) : HedvigNavKey, CrossSellEligibleDestination -``` - -Delete: - -```kotlin -val helpCenterCrossSellBottomSheetPermittingDestinations: List> = listOf( - HelpCenterKey::class, - HelpCenterTopicKey::class, - HelpCenterQuestionKey::class, -) -``` - -Then remove the now-unused `import kotlin.reflect.KClass` and add `import com.hedvig.android.navigation.common.CrossSellEligibleDestination`. (`KType`/`typeOf` stay — used by `EmergencyKey`/`FirstVetKey`.) - -- [ ] **Step 5: Switch `HedvigAppState` cross-sell check to the marker** - -In `HedvigAppState.kt`: - -Remove these four imports (lines 20-23): - -```kotlin -import com.hedvig.android.feature.help.center.navigation.helpCenterCrossSellBottomSheetPermittingDestinations -import com.hedvig.android.feature.home.home.navigation.homeCrossSellBottomSheetPermittingDestinations -import com.hedvig.android.feature.insurances.navigation.insurancesCrossSellBottomSheetPermittingDestinations -import com.hedvig.android.feature.travelcertificate.navigation.travelCertificateCrossSellBottomSheetPermittingDestinations -``` - -Add this import (keep alphabetical ordering in the `com.hedvig.android.navigation.common` group): - -```kotlin -import com.hedvig.android.navigation.common.CrossSellEligibleDestination -``` - -Remove the now-unused `import kotlin.reflect.KClass` (line 32). - -Replace the `isInScreenEligibleForCrossSells` getter (lines 115-119): - -```kotlin - val isInScreenEligibleForCrossSells: Boolean - get() { - val destination = currentDestination ?: return false - return crossSellBottomSheetPermittingDestinations.any { it.isInstance(destination) } - } -``` - -with: - -```kotlin - val isInScreenEligibleForCrossSells: Boolean - get() = currentDestination is CrossSellEligibleDestination -``` - -Delete the entire private val + its KDoc at the bottom of the file (lines 183-197): - -```kotlin -/** - * Destinations that must show the cross-sell bottom sheet after finishing some flow - */ -private val crossSellBottomSheetPermittingDestinations: List> = buildList { - // Screens that a member will end up in after finishing any of the following flows - // 1. Moving flow - // 2. Edit co-insured - // 3. Add/Upgrade addon - // 4. Change tier - addAll(insurancesCrossSellBottomSheetPermittingDestinations) - addAll(helpCenterCrossSellBottomSheetPermittingDestinations) - addAll(travelCertificateCrossSellBottomSheetPermittingDestinations) - // One could finish those flows after a deep link, so the app's start destination must also be included - addAll(homeCrossSellBottomSheetPermittingDestinations) -} -``` - -Note: `HedvigNavKey` import stays — still used by the `currentDestination` getter (removed later in Task 7). - -- [ ] **Step 6: Build + test** - -Run: `./gradlew :app:testDebugUnitTest` -Expected: `BUILD SUCCESSFUL`, existing tests pass. - -- [ ] **Step 7: ktlint** - -Run: `./gradlew ktlintFormat && ./gradlew ktlintCheck` -Expected: `BUILD SUCCESSFUL`. - -- [ ] **Step 8: Commit** - -```bash -git add app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/navigation/HomeDestinations.kt app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/navigation/InsurancesNavigation.kt app/feature/feature-travel-certificate/src/main/kotlin/com/hedvig/android/feature/travelcertificate/navigation/TravelCertificateDestination.kt app/feature/feature-help-center/src/commonMain/kotlin/com/hedvig/android/feature/help/center/navigation/HelpCenterDestination.kt app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigAppState.kt -git commit -m "$(cat <<'EOF' -refactor(nav): mark cross-sell-eligible destinations via marker interface - -Replaces the four per-feature CrossSellBottomSheetPermittingDestinations -lists and HedvigAppState's aggregating buildList with a -CrossSellEligibleDestination marker implemented per key. -EOF -)" -``` - ---- - -### Task 3: Convert chat-notification suppression to `SuppressesChatPushNotification` - -**Files:** -- Modify: `app/feature/feature-chat/src/main/kotlin/com/hedvig/android/feature/chat/navigation/ChatDestination.kt` -- Modify: `app/feature/feature-claim-details/src/main/kotlin/com/hedvig/android/feature/claim/details/navigation/ClaimDetailDestinations.kt` -- Modify: `app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/navigation/HomeDestinations.kt` -- Modify: `app/app/src/main/kotlin/com/hedvig/android/app/notification/senders/ChatNotificationSender.kt` - -- [ ] **Step 1: Mark chat keys** - -In `ChatDestination.kt`, final file: - -```kotlin -package com.hedvig.android.feature.chat.navigation - -import com.hedvig.android.navigation.common.HedvigNavKey -import com.hedvig.android.navigation.common.SuppressesChatPushNotification -import kotlinx.serialization.Serializable - -@Serializable -data object InboxKey : HedvigNavKey, SuppressesChatPushNotification - -@Serializable -data class ChatKey( - val conversationId: String, -) : HedvigNavKey, SuppressesChatPushNotification -``` - -- [ ] **Step 2: Mark `ClaimDetailsKey`** - -In `ClaimDetailDestinations.kt`, add the import and the marker to `ClaimDetailsKey` only (NOT `AddFilesKey`): - -```kotlin -package com.hedvig.android.feature.claim.details.navigation - -import com.hedvig.android.navigation.common.HedvigNavKey -import com.hedvig.android.navigation.common.SuppressesChatPushNotification -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class ClaimDetailsKey( - /** - * The ID to the claim. Must match the name of the param inside in HedvigDeepLinkContainer - */ - @SerialName("claimId") - val claimId: String, -) : HedvigNavKey, SuppressesChatPushNotification - -@Serializable -internal data class AddFilesKey( - val targetUploadUrl: String, - val initialFilesUri: List, -) : HedvigNavKey -``` - -- [ ] **Step 3: Add the chat-suppression marker to `HomeKey`** - -In `HomeDestinations.kt`, `HomeKey` already implements `CrossSellEligibleDestination` from Task 2. Add the second marker and its import. Change: - -```kotlin -import com.hedvig.android.navigation.common.CrossSellEligibleDestination -import com.hedvig.android.navigation.common.HedvigNavKey -import com.hedvig.android.navigation.common.NavKeyTypeAware -``` - -to: - -```kotlin -import com.hedvig.android.navigation.common.CrossSellEligibleDestination -import com.hedvig.android.navigation.common.HedvigNavKey -import com.hedvig.android.navigation.common.NavKeyTypeAware -import com.hedvig.android.navigation.common.SuppressesChatPushNotification -``` - -and change: - -```kotlin -@Serializable -data object HomeKey : HedvigNavKey, CrossSellEligibleDestination -``` - -to: - -```kotlin -@Serializable -data object HomeKey : HedvigNavKey, CrossSellEligibleDestination, SuppressesChatPushNotification -``` - -- [ ] **Step 4: Switch `ChatNotificationSender` suppression check to the marker** - -In `ChatNotificationSender.kt`: - -Remove these four feature-key imports (lines 23-26): - -```kotlin -import com.hedvig.android.feature.chat.navigation.ChatKey -import com.hedvig.android.feature.chat.navigation.InboxKey -import com.hedvig.android.feature.claim.details.navigation.ClaimDetailsKey -import com.hedvig.android.feature.home.home.navigation.HomeKey -``` - -Add (in the `com.hedvig.android.navigation.common` import group): - -```kotlin -import com.hedvig.android.navigation.common.SuppressesChatPushNotification -``` - -Delete the `listOfDestinationsWhichShouldNotShowChatNotification` val (lines 50-55): - -```kotlin -private val listOfDestinationsWhichShouldNotShowChatNotification = setOf( - ChatKey::class, - InboxKey::class, - HomeKey::class, - ClaimDetailsKey::class, -) -``` - -Replace the suppression-check block inside `sendNotification` (lines 68-72): - -```kotlin - val currentDestination = CurrentDestinationInMemoryStorage.currentDestination - val currentlyOnDestinationWhichForbidsShowingChatNotification = - listOfDestinationsWhichShouldNotShowChatNotification.any { clazz -> - clazz.isInstance(currentDestination) - } -``` - -with: - -```kotlin - val currentDestination = CurrentDestinationInMemoryStorage.currentDestination - val currentlyOnDestinationWhichForbidsShowingChatNotification = - currentDestination is SuppressesChatPushNotification -``` - -(`CurrentDestinationInMemoryStorage` is still used here — it is replaced in Task 6. The `HedvigNavKey` import stays — still used by the `CurrentDestinationInMemoryStorage` object declaration.) - -- [ ] **Step 5: Build** - -Run: `./gradlew :app:compileDebugKotlin` -Expected: `BUILD SUCCESSFUL`. - -- [ ] **Step 6: ktlint** - -Run: `./gradlew ktlintFormat && ./gradlew ktlintCheck` -Expected: `BUILD SUCCESSFUL`. - -- [ ] **Step 7: Commit** - -```bash -git add app/feature/feature-chat/src/main/kotlin/com/hedvig/android/feature/chat/navigation/ChatDestination.kt app/feature/feature-claim-details/src/main/kotlin/com/hedvig/android/feature/claim/details/navigation/ClaimDetailDestinations.kt app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/navigation/HomeDestinations.kt app/app/src/main/kotlin/com/hedvig/android/app/notification/senders/ChatNotificationSender.kt -git commit -m "$(cat <<'EOF' -refactor(nav): suppress chat notifications via marker interface - -Replaces listOfDestinationsWhichShouldNotShowChatNotification with a -SuppressesChatPushNotification marker on ChatKey, InboxKey, HomeKey and -ClaimDetailsKey. -EOF -)" -``` - ---- - -### Task 4: Mark `ProfileKey` as `DeliberateLogoutOrigin` and delete dead code - -**Files:** -- Modify: `app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/navigation/ProfileDestinations.kt` - -- [ ] **Step 1: Add the marker and delete the dead val** - -Final file: - -```kotlin -package com.hedvig.android.feature.profile.navigation - -import com.hedvig.android.navigation.common.DeliberateLogoutOrigin -import com.hedvig.android.navigation.common.HedvigNavKey -import kotlinx.serialization.Serializable - -@Serializable -data object ProfileKey : HedvigNavKey, DeliberateLogoutOrigin - -@Serializable -data object ContactInfoKey : HedvigNavKey - -@Serializable -internal data object EurobonusKey : HedvigNavKey - -@Serializable -internal data object CertificatesKey : HedvigNavKey - -@Serializable -internal data object InformationKey : HedvigNavKey - -@Serializable -internal data object LicensesKey : HedvigNavKey - -@Serializable -internal data object SettingsKey : HedvigNavKey -``` - -This adds the `DeliberateLogoutOrigin` import + marker, and removes both the `import kotlin.reflect.KClass` and the dead `destinationToExcludeFromSavingState` val (verified to have zero references outside its own declaration). - -- [ ] **Step 2: Build** - -Run: `./gradlew :app:compileDebugKotlin` -Expected: `BUILD SUCCESSFUL`. - -- [ ] **Step 3: ktlint** - -Run: `./gradlew ktlintFormat && ./gradlew ktlintCheck` -Expected: `BUILD SUCCESSFUL`. - -- [ ] **Step 4: Commit** - -```bash -git add app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/navigation/ProfileDestinations.kt -git commit -m "$(cat <<'EOF' -refactor(nav): mark ProfileKey as DeliberateLogoutOrigin - -Adds the marker and removes the dead destinationToExcludeFromSavingState -val it replaces. -EOF -)" -``` - ---- - -### Task 5: Reinstate "deliberate logout from Profile discards the session" in `setLoggedOut` - -This is the lost Nav2 feature: logging out while on Profile is the normal "I deliberately log out now" action, so the session must NOT be stashed for a same-member restore (restoring the nav back to Profile after a fresh login is wrong). - -**Files:** -- Modify: `app/app/src/test/kotlin/com/hedvig/android/app/navigation/BackstackControllerTest.kt` -- Modify: `app/app/src/main/kotlin/com/hedvig/android/app/navigation/BackstackController.kt` - -- [ ] **Step 1: Adjust the existing stash test so it no longer logs out from a deliberate-logout origin** - -The existing test `setLoggedOut stashes the live session tagged with the member id` (lines 157-169) currently navigates to Profile and expects a stash — that contradicts the new behavior. Change it to land on a non-deliberate-origin destination (Payments) while still exercising parked-run capture. Replace the test body: - -```kotlin - @Test - fun `setLoggedOut stashes the live session tagged with the member id`() { - val controller = controllerWith(HomeKey, InsurancesKey, HelpCenterKey) - controller.selectTopLevel(TopLevelGraph.Profile) // park Insurances run, render Profile root - controller.setLoggedOut("mem-1") - assertThat(controller.entries.toList()).containsExactly(LoginKey) - assertThat(controller.parkedRuns).isEmpty() - val stash = controller.stashedSession!! - assertThat(stash.memberId).isEqualTo("mem-1") - assertThat(stash.entries).containsExactly(HomeKey, ProfileKey) - assertThat(stash.parkedRuns[TopLevelGraph.Insurances]) - .isEqualTo(listOf(InsurancesKey, HelpCenterKey)) - } -``` - -with: - -```kotlin - @Test - fun `setLoggedOut stashes the live session tagged with the member id`() { - val controller = controllerWith(HomeKey, InsurancesKey, HelpCenterKey) - controller.selectTopLevel(TopLevelGraph.Payments) // park Insurances run, render Payments root - controller.setLoggedOut("mem-1") - assertThat(controller.entries.toList()).containsExactly(LoginKey) - assertThat(controller.parkedRuns).isEmpty() - val stash = controller.stashedSession!! - assertThat(stash.memberId).isEqualTo("mem-1") - assertThat(stash.entries).containsExactly(HomeKey, PaymentsKey) - assertThat(stash.parkedRuns[TopLevelGraph.Insurances]) - .isEqualTo(listOf(InsurancesKey, HelpCenterKey)) - } -``` - -(`PaymentsKey` is already imported at line 17.) - -- [ ] **Step 2: Add the new failing test** - -Add this test immediately after the test edited in Step 1: - -```kotlin - @Test - fun `setLoggedOut from a deliberate-logout origin stashes nothing even with a member id`() { - val controller = controllerWith(HomeKey, ProfileKey) - controller.setLoggedOut("mem-1") - assertThat(controller.entries.toList()).containsExactly(LoginKey) - assertThat(controller.parkedRuns).isEmpty() - assertThat(controller.stashedSession).isEqualTo(null) - } -``` - -- [ ] **Step 3: Run tests to verify the new test fails** - -Run: `./gradlew :app:testDebugUnitTest --tests "com.hedvig.android.app.navigation.BackstackControllerTest"` -Expected: the Step-1 test PASSES (lands on Payments, still stashes); the Step-2 test FAILS — `stashedSession` is non-null because `setLoggedOut` currently always stashes when `memberId != null`. - -- [ ] **Step 4: Implement the behavior in `setLoggedOut`** - -In `BackstackController.kt`, add the import (in the `com.hedvig.android.navigation.common` group, after the `HedvigNavKey` import on line 23): - -```kotlin -import com.hedvig.android.navigation.common.DeliberateLogoutOrigin -``` - -Replace `setLoggedOut` (lines 260-270): - -```kotlin - fun setLoggedOut(memberId: String?) { - Snapshot.withMutableSnapshot { - stashedSession = if (memberId != null) { - StashedSession(memberId, entries.toList(), parkedRuns.toMap()) - } else { - null - } - parkedRuns.clear() - entries.replaceWith(listOf(LoginKey)) - } - } -``` - -with: - -```kotlin - fun setLoggedOut(memberId: String?) { - Snapshot.withMutableSnapshot { - val isDeliberateLogout = entries.lastOrNull() is DeliberateLogoutOrigin - stashedSession = if (memberId != null && !isDeliberateLogout) { - StashedSession(memberId, entries.toList(), parkedRuns.toMap()) - } else { - null - } - parkedRuns.clear() - entries.replaceWith(listOf(LoginKey)) - } - } -``` - -Also update the KDoc above `setLoggedOut` (lines 254-259) to document the new case. Replace: - -```kotlin - /** - * Drop to the login root. Stashes the live session (tagged with [memberId]) so a same-member - * re-login can restore the history; the stash is excluded from [allLiveContentKeys], so the - * decorators dispose every key's per-entry state while it waits. A null [memberId] (demo mode / - * unknown identity) stashes nothing — that session can never be safely restored. - */ -``` - -with: - -```kotlin - /** - * Drop to the login root. Stashes the live session (tagged with [memberId]) so a same-member - * re-login can restore the history; the stash is excluded from [allLiveContentKeys], so the - * decorators dispose every key's per-entry state while it waits. A null [memberId] (demo mode / - * unknown identity) stashes nothing — that session can never be safely restored. Logging out while - * the top destination is a [DeliberateLogoutOrigin] (Profile) is treated as an intentional sign-out, - * so nothing is stashed even with a known [memberId] — restoring the member onto that screen would - * be wrong. - */ -``` - -- [ ] **Step 5: Run tests to verify they pass** - -Run: `./gradlew :app:testDebugUnitTest --tests "com.hedvig.android.app.navigation.BackstackControllerTest"` -Expected: PASS (both the edited stash test and the new deliberate-logout test). - -- [ ] **Step 6: ktlint** - -Run: `./gradlew ktlintFormat && ./gradlew ktlintCheck` -Expected: `BUILD SUCCESSFUL`. - -- [ ] **Step 7: Commit** - -```bash -git add app/app/src/main/kotlin/com/hedvig/android/app/navigation/BackstackController.kt app/app/src/test/kotlin/com/hedvig/android/app/navigation/BackstackControllerTest.kt -git commit -m "$(cat <<'EOF' -feat(nav): discard session on deliberate logout from Profile - -setLoggedOut no longer stashes the session for a same-member restore when -the top destination is a DeliberateLogoutOrigin, reinstating the Nav2 -behavior lost in the Nav3 migration. -EOF -)" -``` - ---- - -### Task 6: Replace `CurrentDestinationInMemoryStorage` with an injected `CurrentDestinationHolder` - -Goal: kill the global mutable `object` (written from a Composable effect, read off the FCM/binder thread) and replace it with a `@SingleIn(AppScope::class)` holder exposing a thread-safe `StateFlow`, written by a dedicated effect and read via constructor injection. - -**Files:** -- Create: `app/app/src/main/kotlin/com/hedvig/android/app/navigation/CurrentDestinationHolder.kt` -- Modify: `app/app/src/main/kotlin/com/hedvig/android/app/notification/senders/ChatNotificationSender.kt` -- Modify: `app/app/src/main/kotlin/com/hedvig/android/app/di/ApplicationMetroProviders.kt` -- Modify: `app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigAppState.kt` -- Modify: `app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigApp.kt` -- Modify: `app/app/src/main/kotlin/com/hedvig/android/app/MainActivity.kt` - -- [ ] **Step 1: Create the holder** - -Create `app/app/src/main/kotlin/com/hedvig/android/app/navigation/CurrentDestinationHolder.kt`: - -```kotlin -package com.hedvig.android.app.navigation - -import com.hedvig.android.core.common.di.AppScope -import com.hedvig.android.navigation.common.HedvigNavKey -import dev.zacsweers.metro.Inject -import dev.zacsweers.metro.SingleIn -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow - -/** - * App-scoped source of truth for the destination currently on top of the rendered stack, published - * as a [StateFlow] so non-Composable consumers (e.g. [com.hedvig.android.app.notification.senders.ChatNotificationSender], - * which runs on the FCM/binder thread) can read it safely. Written by `ReportCurrentDestinationEffect`. - * - * This is intentionally non-persistent: a process death wipes it, which is the desired behavior — - * the suppression it powers only matters while the app is resumed, and over-showing a notification is - * preferable to wrongly hiding one. - */ -@SingleIn(AppScope::class) -@Inject -class CurrentDestinationHolder { - private val currentDestinationState = MutableStateFlow(null) - val currentDestination: StateFlow = currentDestinationState.asStateFlow() - - fun update(destination: HedvigNavKey?) { - currentDestinationState.value = destination - } -} -``` - -- [ ] **Step 2: Make `ChatNotificationSender` read the injected holder; remove the global object** - -In `ChatNotificationSender.kt`: - -Delete the `CurrentDestinationInMemoryStorage` object and its KDoc (lines 37-48): - -```kotlin -/** - * An in-memory storage of the current route, used to *not* show the chat notification if we are in a select list of - * screens where we do not want to show the system notification, but we want to let the in-app screen indicate that - * there is a new message. - * This is not persistent storage, and will just be wiped in scenarios like the process being killed, but this is part - * of what we want, since we only care to do this if the app is resumed anyway. On top of this, we'd rather experience - * cases where we show the notification when we shouldn't rather than cases where we do not show the notification even - * thought we should. - */ -object CurrentDestinationInMemoryStorage { - var currentDestination: HedvigNavKey? = null -} -``` - -Add the holder as a constructor parameter. Change the class header: - -```kotlin -class ChatNotificationSender( - private val context: Context, - private val permissionManager: PermissionManager, - private val buildConstants: HedvigBuildConstants, - private val hedvigDeepLinkContainer: HedvigDeepLinkContainer, - private val notificationChannel: HedvigNotificationChannel, -) : NotificationSender { -``` - -to: - -```kotlin -class ChatNotificationSender( - private val context: Context, - private val permissionManager: PermissionManager, - private val buildConstants: HedvigBuildConstants, - private val hedvigDeepLinkContainer: HedvigDeepLinkContainer, - private val notificationChannel: HedvigNotificationChannel, - private val currentDestinationHolder: CurrentDestinationHolder, -) : NotificationSender { -``` - -Change the read inside `sendNotification`: - -```kotlin - val currentDestination = CurrentDestinationInMemoryStorage.currentDestination -``` - -to: - -```kotlin - val currentDestination = currentDestinationHolder.currentDestination.value -``` - -Add the import: - -```kotlin -import com.hedvig.android.app.navigation.CurrentDestinationHolder -``` - -The `HedvigNavKey` import is now unused here (the object that referenced it is gone, and `is SuppressesChatPushNotification` does not name it) — remove `import com.hedvig.android.navigation.common.HedvigNavKey`. - -- [ ] **Step 3: Inject the holder into the Metro provider** - -In `ApplicationMetroProviders.kt`, replace `provideChatNotificationSender` (lines 210-224): - -```kotlin - @Provides - @SingleIn(AppScope::class) - @IntoSet - fun provideChatNotificationSender( - applicationContext: Context, - permissionManager: PermissionManager, - buildConstants: HedvigBuildConstants, - deepLinkContainer: HedvigDeepLinkContainer, - ): NotificationSender = ChatNotificationSender( - applicationContext, - permissionManager, - buildConstants, - deepLinkContainer, - HedvigNotificationChannel.Chat, - ) -``` - -with: - -```kotlin - @Provides - @SingleIn(AppScope::class) - @IntoSet - fun provideChatNotificationSender( - applicationContext: Context, - permissionManager: PermissionManager, - buildConstants: HedvigBuildConstants, - deepLinkContainer: HedvigDeepLinkContainer, - currentDestinationHolder: CurrentDestinationHolder, - ): NotificationSender = ChatNotificationSender( - applicationContext, - permissionManager, - buildConstants, - deepLinkContainer, - HedvigNotificationChannel.Chat, - currentDestinationHolder, - ) -``` - -Add the import (alphabetical, in the `com.hedvig.android.app.navigation` group): - -```kotlin -import com.hedvig.android.app.navigation.CurrentDestinationHolder -``` - -- [ ] **Step 4: Remove the destination-mirroring effect from `rememberHedvigAppState`** - -In `HedvigAppState.kt`, delete the `LaunchedEffect` block (lines 69-74): - -```kotlin - LaunchedEffect(appState) { - snapshotFlow { appState.currentDestination }.collect { destination -> - logcat { "Navigated to destination:$destination" } - CurrentDestinationInMemoryStorage.currentDestination = destination - } - } -``` - -so `rememberHedvigAppState` ends with `return appState` directly after the `remember { … }` block. - -Remove the now-unused imports: - -```kotlin -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.snapshotFlow -import com.hedvig.android.app.notification.senders.CurrentDestinationInMemoryStorage -import com.hedvig.android.logger.logcat -``` - -(`collectAsState` and `getValue` stay — used by `darkTheme`.) - -- [ ] **Step 5: Add `ReportCurrentDestinationEffect` and thread the holder through `HedvigApp`** - -In `HedvigApp.kt`: - -Add the holder parameter to the `HedvigApp` signature, after `missedPaymentNotificationServiceProvider` (line 93): - -```kotlin - missedPaymentNotificationServiceProvider: Provider, - currentDestinationHolder: CurrentDestinationHolder, - dismissSplashScreen: () -> Unit, -``` - -Add a call to the new effect right after `rememberHedvigAppState(...)` returns (immediately after line 103's closing `)` of the `rememberHedvigAppState` call, before `val lastKnownMemberId`): - -```kotlin - ReportCurrentDestinationEffect(backstackController, currentDestinationHolder) -``` - -Add the effect as a private composable (place it next to the other private effects, e.g. after `DetermineStartDestinationEffect`): - -```kotlin -/** - * Mirrors the current top destination into the app-scoped [CurrentDestinationHolder] so non-Composable - * consumers (chat-notification suppression) can read it. Replaces the old LaunchedEffect that wrote the - * global CurrentDestinationInMemoryStorage object. - */ -@Composable -private fun ReportCurrentDestinationEffect( - backstackController: BackstackController, - currentDestinationHolder: CurrentDestinationHolder, -) { - LaunchedEffect(backstackController, currentDestinationHolder) { - snapshotFlow { backstackController.currentDestination }.collect { destination -> - logcat { "Navigated to destination:$destination" } - currentDestinationHolder.update(destination) - } - } -} -``` - -Add the import: - -```kotlin -import com.hedvig.android.app.navigation.CurrentDestinationHolder -``` - -(`LaunchedEffect`, `snapshotFlow`, `logcat`, `Composable` are all already imported in `HedvigApp.kt`.) - -- [ ] **Step 6: Inject the holder in `MainActivity` and pass it to `HedvigApp`** - -In `MainActivity.kt`: - -Add the injected field next to the other `@Inject` fields (e.g. after `missedPaymentNotificationServiceProvider`, line 96): - -```kotlin - @Inject private lateinit var currentDestinationHolder: CurrentDestinationHolder -``` - -Pass it to `HedvigApp` (after `missedPaymentNotificationServiceProvider = …`, line 204): - -```kotlin - missedPaymentNotificationServiceProvider = missedPaymentNotificationServiceProvider, - currentDestinationHolder = currentDestinationHolder, - dismissSplashScreen = { showSplash.update { false } }, -``` - -Add the import: - -```kotlin -import com.hedvig.android.app.navigation.CurrentDestinationHolder -``` - -- [ ] **Step 7: Build + test** - -Run: `./gradlew :app:testDebugUnitTest` -Expected: `BUILD SUCCESSFUL`, all tests pass. - -- [ ] **Step 8: ktlint** - -Run: `./gradlew ktlintFormat && ./gradlew ktlintCheck` -Expected: `BUILD SUCCESSFUL`. - -- [ ] **Step 9: Commit** - -```bash -git add app/app/src/main/kotlin/com/hedvig/android/app/navigation/CurrentDestinationHolder.kt app/app/src/main/kotlin/com/hedvig/android/app/notification/senders/ChatNotificationSender.kt app/app/src/main/kotlin/com/hedvig/android/app/di/ApplicationMetroProviders.kt app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigAppState.kt app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigApp.kt app/app/src/main/kotlin/com/hedvig/android/app/MainActivity.kt -git commit -m "$(cat <<'EOF' -refactor(nav): replace global current-destination storage with injected holder - -Introduces a @SingleIn(AppScope) CurrentDestinationHolder exposing a -StateFlow, written by ReportCurrentDestinationEffect and injected into -ChatNotificationSender, removing the global-mutable -CurrentDestinationInMemoryStorage object. -EOF -)" -``` - ---- - -### Task 7: Split the seam — remove `HedvigAppState` forwarders, thread the controller into `HedvigNavHost` - -After this task, `HedvigAppState` exposes only feature-knowledge state plus the `backstackController` reference (the bridge). All pure-navigation calls go straight to the controller. - -**Files:** -- Modify: `app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigAppState.kt` -- Modify: `app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigNavHost.kt` -- Modify: `app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigAppUi.kt` -- Modify: `app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigApp.kt` - -- [ ] **Step 1: Change `HedvigNavHost` to take the controller + windowSizeClass directly** - -In `HedvigNavHost.kt`: - -Change the signature (lines 106-122). Replace: - -```kotlin -internal fun HedvigNavHost( - hedvigAppState: HedvigAppState, - memberIdService: MemberIdService, -``` - -with: - -```kotlin -internal fun HedvigNavHost( - backstackController: BackstackController, - windowSizeClass: WindowSizeClass, - memberIdService: MemberIdService, -``` - -Replace the alias line (line 123): - -```kotlin - val backstack = hedvigAppState.backstackController -``` - -with: - -```kotlin - val backstack = backstackController -``` - -Now replace every remaining `hedvigAppState.backstackController` with `backstackController`, every `hedvigAppState.windowSizeClass` with `windowSizeClass`, every `hedvigAppState.navigateToTopLevelGraph(...)` with `backstackController.selectTopLevel(...)`, and the `hedvigAppState.navigateToLoggedIn(...)` call with `backstackController.setLoggedIn(...)`. Concretely: - -- Line 143: `val retainedContentKeys = { hedvigAppState.backstackController.allLiveContentKeys }` → `val retainedContentKeys = { backstackController.allLiveContentKeys }` -- Line 154: `backStack = hedvigAppState.backstackController.entries,` → `backStack = backstackController.entries,` -- Lines 156-159 (`onBack`): `if (!hedvigAppState.backstackController.handleBack()) {` → `if (!backstackController.handleBack()) {` -- Line 177: `backstack = hedvigAppState.backstackController,` (loginGraph) → `backstack = backstackController,` -- Lines 182-186 (`onNavigateToLoggedIn`): - - ```kotlin - onNavigateToLoggedIn = { - scope.launch { - hedvigAppState.navigateToLoggedIn(memberIdService.getMemberId().first()) - } - }, - ``` - - → - - ```kotlin - onNavigateToLoggedIn = { - scope.launch { - backstackController.setLoggedIn(memberIdService.getMemberId().first()) - } - }, - ``` -- Line 191: `backstack = hedvigAppState.backstackController,` (nestedHomeGraphs) → `backstack = backstackController,` -- Line 202: `backstack = hedvigAppState.backstackController,` (homeGraph) → `backstack = backstackController,` -- Line 228: `windowSizeClass = hedvigAppState.windowSizeClass,` → `windowSizeClass = windowSizeClass,` -- Line 229: `backstack = hedvigAppState.backstackController,` (terminateInsuranceGraph) → `backstack = backstackController,` -- Lines 233-236 (`navigateToInsurances`): - - ```kotlin - navigateToInsurances = { - backstack.popUpTo(inclusive = true) - hedvigAppState.navigateToTopLevelGraph(TopLevelGraph.Insurances) - }, - ``` - - → - - ```kotlin - navigateToInsurances = { - backstack.popUpTo(inclusive = true) - backstackController.selectTopLevel(TopLevelGraph.Insurances) - }, - ``` -- Line 265: `backstack = hedvigAppState.backstackController,` (insuranceGraph) → `backstack = backstackController,` -- Line 317: `backstack = hedvigAppState.backstackController,` (paymentsGraph) → `backstack = backstackController,` -- Line 325: `backstack = hedvigAppState.backstackController,` (payoutAccountGraph) → `backstack = backstackController,` -- Line 332: `deleteAccountGraph(hedvigAppState.backstackController)` → `deleteAccountGraph(backstackController)` -- Line 341: `backstack = hedvigAppState.backstackController,` (profileGraph) → `backstack = backstackController,` -- Line 369: `backstack = hedvigAppState.backstackController,` (cbmChatGraph) → `backstack = backstackController,` -- Line 372: `backstack = hedvigAppState.backstackController,` (addonPurchaseNavGraph) → `backstack = backstackController,` -- Line 381: `backstack = hedvigAppState.backstackController,` (changeTierGraph) → `backstack = backstackController,` -- Line 385: `backstack = hedvigAppState.backstackController,` (chipIdGraph) → `backstack = backstackController,` -- Lines 389-392 (`goHome`): - - ```kotlin - goHome = { - backstack.popUpTo(inclusive = true) - hedvigAppState.navigateToTopLevelGraph(TopLevelGraph.Home) - }, - ``` - - → - - ```kotlin - goHome = { - backstack.popUpTo(inclusive = true) - backstackController.selectTopLevel(TopLevelGraph.Home) - }, - ``` -- Line 395: `backstack = hedvigAppState.backstackController,` (movingFlowGraph) → `backstack = backstackController,` -- Line 398: `connectPaymentGraph(backstack = hedvigAppState.backstackController)` → `connectPaymentGraph(backstack = backstackController)` -- Line 399: `editCoInsuredGraph(hedvigAppState.backstackController)` → `editCoInsuredGraph(backstackController)` -- Line 401: `backstack = hedvigAppState.backstackController,` (helpCenterGraph) → `backstack = backstackController,` -- Line 458: `imageViewerGraph(hedvigAppState.backstackController, imageLoader)` → `imageViewerGraph(backstackController, imageLoader)` -- Line 459: `removeAddonsNavGraph(backstack = hedvigAppState.backstackController)` → `removeAddonsNavGraph(backstack = backstackController)` - -After these replacements there must be zero remaining `hedvigAppState` references in the file. Verify with: `grep -n "hedvigAppState" app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigNavHost.kt` → no matches. - -Fix imports: remove `import com.hedvig.android.app.ui.HedvigAppState` (line 20). Add `import androidx.compose.material3.windowsizeclass.WindowSizeClass` (alphabetical within the `androidx.compose` group). `BackstackController` is in the same package — no import needed. - -- [ ] **Step 2: Update `HedvigAppUi` to read top-level via the controller and pass new args to `HedvigNavHost`** - -In `HedvigAppUi.kt`: - -Replace line 70: - -```kotlin - currentTopLevelGraph = hedvigAppState.currentTopLevelGraph, -``` - -with: - -```kotlin - currentTopLevelGraph = hedvigAppState.backstackController.currentTopLevel, -``` - -Replace line 71: - -```kotlin - onNavigateToTopLevelGraph = hedvigAppState::navigateToTopLevelGraph, -``` - -with: - -```kotlin - onNavigateToTopLevelGraph = hedvigAppState.backstackController::selectTopLevel, -``` - -Replace the `HedvigNavHost(...)` call (lines 95-110). Replace: - -```kotlin - HedvigNavHost( - hedvigAppState = hedvigAppState, - memberIdService = memberIdService, -``` - -with: - -```kotlin - HedvigNavHost( - backstackController = hedvigAppState.backstackController, - windowSizeClass = hedvigAppState.windowSizeClass, - memberIdService = memberIdService, -``` - -(`hedvigAppState.backstackController.navigateUp()` on line 82 and `hedvigAppState.backstackController.loneDeepLinkChrome` on line 85 stay as-is — they already go through the controller.) - -- [ ] **Step 3: Update the `HedvigApp` effects to call the controller directly** - -In `HedvigApp.kt`: - -`DetermineStartDestinationEffect` already receives `backstackController`. Replace its callback args (lines 115-116): - -```kotlin - onLoggedIn = { memberId -> hedvigAppState.navigateToLoggedIn(memberId) }, - onLoggedOut = { hedvigAppState.navigateToLoggedOut(lastKnownMemberId.value) }, -``` - -with: - -```kotlin - onLoggedIn = { memberId -> backstackController.setLoggedIn(memberId) }, - onLoggedOut = { backstackController.setLoggedOut(lastKnownMemberId.value) }, -``` - -Change the `LogoutOnInvalidCredentialsEffect` call site (lines 133-138): - -```kotlin - LogoutOnInvalidCredentialsEffect( - hedvigAppState, - authTokenService, - demoManager, - lastKnownMemberId = { lastKnownMemberId.value }, - ) -``` - -with: - -```kotlin - LogoutOnInvalidCredentialsEffect( - backstackController, - authTokenService, - demoManager, - lastKnownMemberId = { lastKnownMemberId.value }, - ) -``` - -Change the `LogoutOnInvalidCredentialsEffect` definition (lines 303-347). Replace its signature parameter and the two body references. Replace: - -```kotlin -private fun LogoutOnInvalidCredentialsEffect( - hedvigAppState: HedvigAppState, - authTokenService: AuthTokenService, - demoManager: DemoManager, - lastKnownMemberId: () -> String?, -) { -``` - -with: - -```kotlin -private fun LogoutOnInvalidCredentialsEffect( - backstackController: BackstackController, - authTokenService: AuthTokenService, - demoManager: DemoManager, - lastKnownMemberId: () -> String?, -) { -``` - -Replace (inside that effect) line 325: - -```kotlin - LaunchedEffect(lifecycle, hedvigAppState, authTokenService, demoManager) { -``` - -with: - -```kotlin - LaunchedEffect(lifecycle, backstackController, authTokenService, demoManager) { -``` - -Replace line 330: - -```kotlin - snapshotFlow { hedvigAppState.backstackController.isLoggedIn }, -``` - -with: - -```kotlin - snapshotFlow { backstackController.isLoggedIn }, -``` - -Replace line 342: - -```kotlin - hedvigAppState.navigateToLoggedOut(lastKnownMemberId()) -``` - -with: - -```kotlin - backstackController.setLoggedOut(lastKnownMemberId()) -``` - -- [ ] **Step 4: Remove the forwarders from `HedvigAppState`** - -In `HedvigAppState.kt`: - -Delete the `currentDestination` getter (lines 88-89): - -```kotlin - val currentDestination: HedvigNavKey? - get() = backstackController.currentDestination -``` - -Delete the `currentTopLevelGraph` getter (lines 91-92): - -```kotlin - val currentTopLevelGraph: TopLevelGraph - get() = backstackController.currentTopLevel -``` - -Delete the three navigation methods (lines 155-169): - -```kotlin - /** - * Navigate to a top level destination. Selecting the current tab again pops it back to its start; - * selecting another tab brings its run forward (or returns to Home), keeping Home pinned at the base. - */ - fun navigateToTopLevelGraph(topLevelGraph: TopLevelGraph) { - backstackController.selectTopLevel(topLevelGraph) - } - - fun navigateToLoggedIn(memberId: String?) { - backstackController.setLoggedIn(memberId) - } - - fun navigateToLoggedOut(memberId: String?) { - backstackController.setLoggedOut(memberId) - } -``` - -Update `isInScreenEligibleForCrossSells` to read the controller directly (it can no longer use the removed `currentDestination` forwarder). Replace: - -```kotlin - val isInScreenEligibleForCrossSells: Boolean - get() = currentDestination is CrossSellEligibleDestination -``` - -with: - -```kotlin - val isInScreenEligibleForCrossSells: Boolean - get() = backstackController.currentDestination is CrossSellEligibleDestination -``` - -Now `HedvigNavKey` is no longer referenced in this file — remove `import com.hedvig.android.navigation.common.HedvigNavKey`. `TopLevelGraph` is still referenced by `topLevelGraphs` — keep its import. - -- [ ] **Step 5: Build + test** - -Run: `./gradlew :app:testDebugUnitTest` -Expected: `BUILD SUCCESSFUL`, all tests pass. - -- [ ] **Step 6: Verify no leftover forwarders / stale references** - -Run: -```bash -grep -rn "navigateToLoggedIn\|navigateToLoggedOut\|navigateToTopLevelGraph\|\.currentTopLevelGraph\b" app/app/src/main/kotlin/com/hedvig/android/app -grep -n "hedvigAppState" app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigNavHost.kt -``` -Expected: no matches for the removed forwarder names; no `hedvigAppState` in `HedvigNavHost.kt`. - -- [ ] **Step 7: ktlint** - -Run: `./gradlew ktlintFormat && ./gradlew ktlintCheck` -Expected: `BUILD SUCCESSFUL`. - -- [ ] **Step 8: Full app assemble (final integration gate)** - -Run: `./gradlew :app:assembleDebug` -Expected: `BUILD SUCCESSFUL`. - -- [ ] **Step 9: Commit** - -```bash -git add app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigAppState.kt app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigNavHost.kt app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigAppUi.kt app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigApp.kt -git commit -m "$(cat <<'EOF' -refactor(nav): split HedvigAppState/BackstackController responsibilities - -Removes HedvigAppState's pure-navigation forwarders (currentDestination, -currentTopLevelGraph, navigateToTopLevelGraph/LoggedIn/LoggedOut) and -threads BackstackController + windowSizeClass directly into HedvigNavHost, -leaving HedvigAppState with only feature-knowledge state plus the -controller bridge. -EOF -)" -``` - ---- - -## Self-Review - -**Spec coverage:** -- (a) HedvigAppState/BackstackController split → Task 7. ✓ -- (b) three marker conversions in navigation-common → Task 1 (declare) + Task 2 (cross-sell) + Task 3 (chat) + Task 4 (logout). ✓ -- (c) DeliberateLogoutOrigin logout reinstatement in `setLoggedOut` → Task 5 (TDD). ✓ -- (d) CurrentDestinationHolder replacement (option A: generic holder, marker check at consumer) → Task 6. ✓ - -**Type consistency:** marker names (`CrossSellEligibleDestination`, `SuppressesChatPushNotification`, `DeliberateLogoutOrigin`) are used identically across Tasks 1–7. Holder API: `CurrentDestinationHolder.currentDestination: StateFlow` + `update(HedvigNavKey?)` — read as `.currentDestination.value` (Task 2/6) and written via `.update(...)` (Task 6). Controller methods used by callers: `selectTopLevel`, `setLoggedIn`, `setLoggedOut`, `currentTopLevel`, `currentDestination`, `isLoggedIn`, `navigateUp`, `loneDeepLinkChrome`, `allLiveContentKeys`, `handleBack`, `entries` — all exist on `BackstackController`. ✓ - -**Placeholder scan:** every code step contains complete code; every command has an expected result. ✓ - -**Ordering safety:** Task 1 only adds interfaces (safe). Tasks 2–4 each add markers + delete the corresponding registry atomically (build green after each). Task 5 is TDD and self-contained. Task 6 swaps storage without touching the split. Task 7 removes forwarders last, after every other consumer is settled. Each task ends with a green build + ktlint + commit. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3c065f28ba..901bcf2887 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -37,7 +37,6 @@ rive = "11.2.1" androidx-activity-compose = "1.13.0" androidx-activity-core = "1.13.0" androidx-annotation = "1.9.1" -androidx-appstate = "1.0.0-SNAPSHOT" androidx-composeBom = "2026.04.01" androidx-datastore = "1.2.0" androidx-junit = "1.3.0" @@ -106,7 +105,6 @@ accompanist-permissions = { module = "com.google.accompanist:accompanist-permiss androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" } androidx-activity-core = { module = "androidx.activity:activity", version.ref = "androidx-activity-core" } androidx-annotation = { module = "androidx.annotation:annotation", version.ref = "androidx-annotation" } -androidx-appstate = { module = "androidx.appstate:appstate", version.ref = "androidx-appstate" } androidx-compose-animation = { module = "androidx.compose.animation:animation" } androidx-compose-animationCore = { module = "androidx.compose.animation:animation-core" } androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidx-composeBom" } diff --git a/settings.gradle.kts b/settings.gradle.kts index c1e5326017..9aac034f85 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -25,13 +25,6 @@ dependencyResolutionManagement { } mavenCentral() maven("https://jitpack.io") - // SPIKE: androidx.dev snapshot build 15580488, only for the unreleased androidx.appstate preview. - maven("https://androidx.dev/snapshots/builds/15580488/artifacts/repository") { - mavenContent { - includeGroup("androidx.appstate") - snapshotsOnly() - } - } } }