From 60be4f2075c2cacb80272a0b43cf2d04b57e8ff9 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 21 Jul 2026 13:29:08 +0200 Subject: [PATCH 001/119] Add onboarding feature design spec --- .../specs/2026-07-21-onboarding-design.md | 249 ++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-21-onboarding-design.md diff --git a/docs/superpowers/specs/2026-07-21-onboarding-design.md b/docs/superpowers/specs/2026-07-21-onboarding-design.md new file mode 100644 index 0000000000..8b87968a99 --- /dev/null +++ b/docs/superpowers/specs/2026-07-21-onboarding-design.md @@ -0,0 +1,249 @@ +# Onboarding feature: design spec + +Date: 2026-07-21 +Figma: https://www.figma.com/design/SogcacjzOxkCC46XcZP8lQ/App-P2-2026?node-id=597-1191&m=dev ("Final Design App Onboarding") + +## Overview + +A post-login onboarding wizard that walks a new member through setting up their account: a +multi-step, full-screen flow with a segmented progress bar, a back arrow, and a close (X) at +the top. The member's step path is computed on-device from a single eagerly-fetched backend +query, so the progress bar count is known before step 1 renders and no mid-flow network +fetch can strand the member between steps. + +Shown once per member: on the first login for a member on this install, and never again once +they complete or dismiss it. + +## Goals + +- Show the wizard exactly once per member, remembered client-side. +- Fetch all flow data in one query before showing the flow; steps never block on network to + render. +- Skip steps that do not apply to this member (already-connected payment, no missing + co-insured, and so on); always show the phone number step for confirmation. +- Keep all step UI inside the onboarding chrome. Performing a real action (adding a + co-insured person, entering a pet ID, connecting Trustly) navigates out to the existing + feature flow; when it finishes, navigation pops back to the onboarding step, which stays in + the back stack. +- Introduce the product analytics consent mechanism (new to the app) as part of this work. + +## Non-goals + +- No backend/schema changes. The client composes the path from existing Octopus fields. +- No iOS work (iOS hosts its own navigation; this is Android-only). +- Lokalise strings: all new copy ships hardcoded in English with `// TODO: Add ... to + Lokalise` comments, per project convention. + +## Module layout + +New module: `app/feature/feature-onboarding/`. + +```kotlin +hedvig { + apollo("octopus") + compose() + serialization() + navKeys() + viewModels() +} +``` + +Owns: the `OnboardingQuery`, `OnboardingData` mapping, the pure path builder, the +`OnboardingSessionStore`, the `OnboardingSeenStore`, all step screens, the gate effect +composable, and `fun EntryProviderScope.onboardingEntries(...)`. + +No `feature-onboarding-navigation` module: only `:app` navigates into onboarding, and `:app` +already depends on the feature module. The root `OnboardingKey` is `public` in the feature +module; per-step keys are `internal`. + +Dependencies (data/infra plus public nav-key modules only, no feature-to-feature violation): + +- `apollo-octopus-public`, `molecule-public`, `navigation-compose`, `design-system-hedvig`, + `core-common-public`, `core-datastore-public` +- `data-settings-datastore-public` (theme + analytics consent) +- `feature-edit-coinsured-navigation` (`EditCoInsuredTriageKey`, `CoInsuredAddInfoKey`) +- `feature-connect-payment-trustly-navigation` (`TrustlyKey`) +- chip-id public key module (`ChipIdKey`) +- auth/member-id access for the per-member seen flag + +## Data: eager fetch and path construction + +One internal `OnboardingQuery` (new `.graphql` file in the module) fetches everything the +flow needs in a single round trip: + +``` +currentMember { + email + phoneNumber + activeContracts { + id + exposureDisplayNameShort + coInsured { firstName lastName hasMissingInfo ... } + isMissingPetId + currentAgreement { productVariant { typeOfContract displayName } } + } + referralInformation { code monthlyDiscountPerReferral } + paymentMethods { payinMethods { ... } } + crossSellV2(input: ...) { recommendedCrossSell { ... } otherCrossSells { ... } } +} +``` + +Mapped to a project-owned `OnboardingData`; `octopus.*` types stay confined to the internal +repository impl, per the data-layer rule. + +`buildOnboardingPath(data: OnboardingData): List` is a pure function and the +primary unit-test target. Step inclusion rules: + +| Step | Included when | +| --- | --- | +| Welcome | always | +| Analytics consent | always (it is the flow's one-time ask) | +| Phone number | always, pre-filled with the current number for confirmation | +| Theme | always | +| Add co-insured | any active contract has a co-insured with `hasMissingInfo == true` | +| Pet ID numbers | any active contract has `isMissingPetId == true` | +| Invite a friend | `referralInformation.code` exists | +| Connect payment | `payinMethods` is empty | +| Bundle discount | eligible cross-sells returned; per the Figma annotation, skipped when the member has accident-only insurance (exact rule read off the `crossSellV2` response at implementation) | + +The path length drives the segmented progress bar. + +## Show-once mechanism + +`OnboardingSeenStore`, DataStore-backed (same pattern as `DeviceIdDataStore` / +`SettingsDataStoreImpl`), with a per-member key: + +``` +booleanPreferencesKey("com.hedvig.android.feature.onboarding.seen.") +``` + +Set to true when the member completes the flow ("Continue to app") or closes it with X. +Never shown again for that member on this install. A different member logging in on the same +device gets their own flag. + +## Gate: when the flow appears + +An effect composable exposed by `feature-onboarding` and placed in `HedvigApp`, at the same +seam as `TryShowAppStoreReviewDialogEffect` and `CrossSellSheet`: + +1. Wait for `AuthStatus.LoggedIn` and session readiness. +2. Read the member id; check `OnboardingSeenStore`. If seen: do nothing. +3. If unseen: run `OnboardingQuery`. On failure: do nothing, leave the flag unset, retry on + next app start. Login/splash is never held on this fetch. +4. On success, compute the path. If non-empty: store `OnboardingData` + path in the + `OnboardingSessionStore` and push `OnboardingKey`, but only while the current destination + is the Home tab root and no deep link is pending, so a deep-linked flow is never + interrupted. + +## Navigation model + +Per-step nav keys over a shared session store: + +- `OnboardingKey` (public): the root, renders the Welcome step. +- `OnboardingStepKey(stepId: OnboardingStepId)` (internal): one entry per subsequent step. + `OnboardingStepId` is a serializable enum. Keys are tiny; `navKeys()` registration gives + process-death survival for free (covered by `ExhaustiveBackStackSerializationTest`). +- `OnboardingSessionStore` is `@SingleIn(ActivityRetainedScope::class)` and holds the fetched + `OnboardingData` and computed path. Progress = index of the current step id in the path. + +Navigation semantics: + +- Continue / Get started: `backstack.add(OnboardingStepKey(next))`. +- Top app bar back arrow: `backstack.navigateUp()` (the one sanctioned use). +- X (any step) and the final "Continue to app": mark seen in `OnboardingSeenStore`, then + remove all onboarding keys from the back stack, landing on Home underneath. +- Navigate-out steps push the existing feature flow (`CoInsuredAddInfoKey`, `ChipIdKey`, + `TrustlyKey`) on top of the onboarding step. When that flow pops, the member lands back on + the onboarding step, which refreshes its slice of data so the "done" variant renders. + +Process death mid-flow: keys restore the position; the session store is empty, so the visible +step shows a loading state, refetches `OnboardingQuery`, and rebuilds the path. If the +rebuilt path no longer contains the restored step (for example payment got connected +elsewhere), pop to the nearest preceding step still in the path. + +## Step screens + +All steps render inside a shared `OnboardingStepScaffold`: segmented progress bar (one +segment per path step, filled up to the current index), back arrow (hidden on Welcome), X. +The segmented progress bar is expected to be a new small component; check +`design-system-hedvig` for an existing one before adding it. + +1. **Welcome**: Hedvig app icon with a red "1" badge (animated), "Welcome to Hedvig", + subtitle, "Get started" button. No back arrow. +2. **Analytics consent**: "Help us make the app better" copy, chart icon with checkmark + animation on Allow, "Privacy policy" external link, Allow / Deny. Writes the tri-state + consent value (see below); both choices advance. +3. **Phone number**: pre-filled text field with the member's current number, "Save" runs + `MemberUpdateContactInfoMutation` (email is fetched in `OnboardingQuery` and resubmitted + unchanged, since the mutation requires both fields), "Do this later" advances without + saving. Inline error + retry on mutation failure. +4. **Choose theme**: System / Light / Dark radio list backed by + `SettingsDataStore.setTheme(...)`; selection applies immediately (live theme change), + "Continue" advances. Hint text: "You can change these settings later". +5. **Add co-insured**: one row per contract with missing co-insured info, "Add" navigates out + to the edit-co-insured flow for that contract. On return the step refreshes and shows the + added names with checkmarks (Figma's done variant). Continue / Do this later. +6. **Pet ID numbers**: one row per pet contract with `isMissingPetId`, "Add" navigates out to + the chip-id flow (`ChipIdKey(contractId)`). Done variant with checkmarks on return. + Continue / Do this later. +7. **Invite a friend**: referral incentive UI built from `referralInformation` + (`monthlyDiscountPerReferral`, campaign code). "Invite a friend" opens the system share + sheet with the code (same share content as the Forever screen). "Continue" advances. +8. **Connect payment**: bank-to-Hedvig animation, "Connect payment" navigates out to the + Trustly flow (`TrustlyKey`). On return, refetch payment status; if connected, show the + connected variant (checkmark, "You can switch accounts later in settings") with + "Continue". +9. **Bundle discount**: cross-sell rows (title, savings text) from `crossSellV2`, each "See + price" opens the external `storeUrl`. "Continue to app" completes the flow (marks seen, + pops everything). + +## Analytics consent mechanism (new, cross-cutting) + +- **Datadog is untouched.** It carries performance and bug analytics, not marketing data; + its consent is hardcoded as granted, forever. Crashlytics is likewise untouched. +- **Only Firebase Analytics is gated.** Consent is tri-state: + `NotDecided` (default), `Granted`, `Denied`, stored as a string-backed enum key in + `SettingsDataStore` (`data-settings-datastore-public`). +- **NotDecided (buffer-and-flush)**: the tracker fan-out queues Firebase-bound events + instead of forwarding them. On `Granted`, the buffer is replayed into `FirebaseAnalytics` + (original event timestamps attached as an event parameter, since replayed events carry + send-time timestamps) and live forwarding begins. On `Denied`, the buffer is dropped and + nothing is forwarded. Whether the buffer is in-memory or persisted is decided at + implementation; in-memory is the starting assumption (worst case, pre-decision events from + earlier process lifetimes are lost, which is acceptable). + Note: Firebase's own `setAnalyticsCollectionEnabled(false)` drops rather than queues, so + the buffer must live in our own fan-out layer, not the SDK. +- **Settings toggle**: a new row in the profile Settings screen (`feature-profile`, + `SettingsPresenter`) reading/writing the same value, fulfilling the design's "can be + turned off any time in settings". +- The onboarding step writes `Granted` on Allow and `Denied` on Deny. + +## Error handling + +- Gate fetch failure: silently skip showing onboarding; flag stays unset; retried next app + start. +- Refetch failure after process-death restore: error state with retry inside the scaffold; + X still dismisses (and marks seen). +- Step mutation failure (phone save): inline error, retry available, "Do this later" always + works. +- Returning from a navigate-out flow: refresh that step's data; on refresh failure keep the + previously shown state (the member can still Continue). + +## Testing + +- `buildOnboardingPath`: one unit test per inclusion/skip rule, plus ordering. +- `OnboardingSeenStore`: per-member key behavior with a fake DataStore. +- Gate use case: shows when unseen + fetch succeeds + path non-empty; stays silent on seen / + fetch failure / empty path / pending deep link. +- Presenter tests (Turbine + Molecule) per step, covering success, failure, and skip events. +- Consent fan-out: NotDecided buffers, Granted flushes then forwards, Denied drops. +- Key serialization: automatic via `ExhaustiveBackStackSerializationTest` once `navKeys()` is + on. + +## Risks / open items (resolved at implementation) + +- Exact Firebase suppression point in the tracking fan-out (which class owns forwarding). +- Whether `design-system-hedvig` already has a segmented progress component. +- The precise `crossSellV2` field expressing "accident-only, skip bundle step". +- The chip-id public key module's exact name/coordinates. +- Trustly flow pop-back behavior on success (verify it pops itself back to the caller). From 868b2160904cad9cbf43b5677de99462227610f8 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 21 Jul 2026 14:38:25 +0200 Subject: [PATCH 002/119] Add onboarding implementation plan --- .../plans/2026-07-21-onboarding.md | 3526 +++++++++++++++++ 1 file changed, 3526 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-21-onboarding.md diff --git a/docs/superpowers/plans/2026-07-21-onboarding.md b/docs/superpowers/plans/2026-07-21-onboarding.md new file mode 100644 index 0000000000..88ead4c276 --- /dev/null +++ b/docs/superpowers/plans/2026-07-21-onboarding.md @@ -0,0 +1,3526 @@ +# Onboarding Feature 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:** A post-login onboarding wizard shown once per member, with a step path computed on-device from one eagerly-fetched GraphQL query, plus the new tri-state Firebase analytics consent mechanism. + +**Architecture:** New `feature-onboarding` module with tiny per-step nav keys over a shared `ActivityRetainedScope` session store (spec's "approach C"). A gate effect in `HedvigApp` pushes the flow on top of Home for unseen members. Consent gating wraps the Firebase tracking client with a buffer-and-flush delegate; Datadog is untouched. + +**Tech Stack:** Kotlin, Jetpack Compose, Molecule (MVI), Apollo GraphQL (octopus), Metro DI, Navigation 3, DataStore Preferences, Turbine/assertK tests. + +**Spec:** `docs/superpowers/specs/2026-07-21-onboarding-design.md` + +## Global Constraints + +- Never use " — " (spaced em-dash) in any prose, comment, or commit message; use comma/period/parens/colon. +- Never add strings to any `strings.xml`. Hardcode English text with a `// TODO: Add "" / "" to Lokalise` comment. +- Apollo-generated `octopus.*` types must never appear in public signatures; confine them to `internal` repository impls. +- All logging through `com.hedvig.android.logger.logcat` (`projects.loggingPublic`); never Timber/Log/println. +- Feature modules cannot depend on feature modules, EXCEPT `feature-{name}-navigation` modules. `ChipIdKey` lives in `feature-chip-id` (not a `-navigation` module), so onboarding must NOT depend on it; thread a lambda from `:app` instead. +- `backstack.navigateUp()` only for the top-app-bar back arrow; every other pop uses `popBackstack()` / removal extensions. +- Run `./gradlew ktlintFormat` before every commit. 2-space indent, max line 120, trailing commas. +- Gradle verification: do NOT trust piped exit codes (`gradlew ... | tail` returns tail's exit code). Grep the output for `BUILD SUCCESSFUL` explicitly. +- Every new `@Serializable` `HedvigNavKey` requires `navKeys()` in the module's `hedvig {}` block and `@androidx.annotation.Keep` on the key class (see `ChipIdNavDestination.kt` precedent). +- Metro: KSP-generated ViewModel contributions are `public` by design; don't "fix" them to internal. +- Commit after every task (small, atomic commits). Branch: `feat/onboarding`. + +## File Structure Overview + +New module `app/feature/feature-onboarding/` (auto-discovered by settings.gradle.kts): + +``` +app/feature/feature-onboarding/ +├── build.gradle.kts +└── src/main/ + ├── graphql/ + │ ├── OnboardingQuery.graphql + │ └── UpdateContactInfoMutation.graphql + └── kotlin/com/hedvig/android/feature/onboarding/ + ├── navigation/ + │ ├── OnboardingKeys.kt (OnboardingKey, OnboardingStepKey, OnboardingStepId) + │ ├── OnboardingEntries.kt (entries function, filled in Task 19) + │ └── OnboardingNavigator.kt (ActivityRetainedScope, continue/exit/back) + ├── data/ + │ ├── OnboardingData.kt (project-owned models) + │ ├── OnboardingRepository.kt (interface + internal Apollo impl) + │ ├── OnboardingPath.kt (buildOnboardingPath pure function) + │ ├── OnboardingSeenStore.kt (per-member DataStore flag) + │ ├── OnboardingSessionStore.kt (ActivityRetainedScope cache of data+path) + │ └── ShouldShowOnboardingUseCase.kt + ├── gate/ + │ └── OnboardingGate.kt (public interface + impl, ActivityRetainedScope) + └── ui/ + ├── OnboardingStepScaffold.kt (progress bar + back + close chrome) + ├── welcome/WelcomeDestination.kt + ├── consent/ConsentStepDestination.kt (+ ViewModel/Presenter) + ├── phone/PhoneStepDestination.kt (+ ViewModel/Presenter) + ├── theme/ThemeStepDestination.kt (+ ViewModel/Presenter) + ├── coinsured/CoInsuredStepDestination.kt (+ ViewModel/Presenter) + ├── petid/PetIdStepDestination.kt (+ ViewModel/Presenter) + ├── invite/InviteStepDestination.kt (+ ViewModel/Presenter) + ├── payment/PaymentStepDestination.kt (+ ViewModel/Presenter) + └── bundle/BundleStepDestination.kt (+ ViewModel/Presenter) +``` + +Modified existing files: + +- `app/data/data-settings-datastore/data-settings-datastore-public/.../SettingsDataStoreImpl.kt` (tri-state consent) +- `app/tracking/tracking-firebase/.../FirebaseEventTrackingClient.kt` (lose the binding annotation) +- `app/tracking/tracking-firebase/.../ConsentAwareEventTrackingClient.kt` (new, becomes the binding) +- `app/tracking/tracking-firebase/build.gradle.kts` (add settings-datastore dep) +- `app/feature/feature-profile/.../settings/SettingsPresenter.kt` + `SettingsDestination.kt` (consent toggle row) +- `app/app/build.gradle.kts` (add feature-onboarding dep) +- `app/app/.../navigation/HedvigEntryProvider.kt` (register onboardingEntries) +- `app/app/.../di/ActivityRetainedGraph.kt` (expose OnboardingGate) +- `app/app/.../ui/HedvigApp.kt` (TryShowOnboardingEffect) +- `app/app/.../MainActivity.kt` (pass gate into HedvigApp) + +--- + +### Task 1: Tri-state AnalyticsConsent in SettingsDataStore + +**Files:** +- Create: `app/data/data-settings-datastore/data-settings-datastore-public/src/main/kotlin/com/hedvig/android/data/settings/datastore/AnalyticsConsent.kt` +- Modify: `app/data/data-settings-datastore/data-settings-datastore-public/src/main/kotlin/com/hedvig/android/data/settings/datastore/SettingsDataStoreImpl.kt` +- Modify: `app/data/data-settings-datastore/data-settings-datastore-public/build.gradle.kts` +- Test: `app/data/data-settings-datastore/data-settings-datastore-public/src/test/kotlin/com/hedvig/android/data/settings/datastore/SettingsDataStoreImplTest.kt` + +**Interfaces:** +- Consumes: existing `SettingsDataStore` interface, `DataStore`. +- Produces: `enum class AnalyticsConsent { NOT_DECIDED, GRANTED, DENIED }`; `SettingsDataStore.setAnalyticsConsent(consent: AnalyticsConsent)` (suspend); `SettingsDataStore.observeAnalyticsConsent(): Flow` (emits `NOT_DECIDED` when unset). Tasks 2, 3, and 12 rely on these exact names. + +- [ ] **Step 1: Create the enum** + +`AnalyticsConsent.kt`: + +```kotlin +package com.hedvig.android.data.settings.datastore + +/** + * Consent for product analytics (Firebase Analytics only; Datadog performance/bug analytics is + * intentionally not covered by this and stays always-on). + * [NOT_DECIDED] means the member has never been asked or never answered: events are buffered + * in-app, not forwarded, until an explicit decision is made. + */ +enum class AnalyticsConsent { + NOT_DECIDED, + GRANTED, + DENIED, +} +``` + +- [ ] **Step 2: Write the failing test** + +The module has no test source set yet. Add test deps to `build.gradle.kts` (append inside the existing `dependencies {}` block): + +```kotlin +testImplementation(libs.assertK) +testImplementation(libs.coroutines.test) +testImplementation(libs.junit) +``` + +`SettingsDataStoreImplTest.kt`: + +```kotlin +package com.hedvig.android.data.settings.datastore + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import assertk.assertThat +import assertk.assertions.isEqualTo +import java.io.File +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toPath +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class SettingsDataStoreImplTest { + @get:Rule + val temporaryFolder: TemporaryFolder = TemporaryFolder() + + // Note: pass a path that does NOT exist yet; DataStore treats a pre-created empty file as corrupt. + private fun settingsDataStore(fileName: String): SettingsDataStoreImpl { + val file = File(temporaryFolder.root, fileName) + return SettingsDataStoreImpl( + PreferenceDataStoreFactory.createWithPath(produceFile = { file.absolutePath.toPath() }), + ) + } + + @Test + fun `analytics consent defaults to NOT_DECIDED when nothing is stored`() = runTest { + val store = settingsDataStore("settings1.preferences_pb") + assertThat(store.observeAnalyticsConsent().first()).isEqualTo(AnalyticsConsent.NOT_DECIDED) + } + + @Test + fun `analytics consent round-trips GRANTED and DENIED`() = runTest { + val store = settingsDataStore("settings2.preferences_pb") + store.setAnalyticsConsent(AnalyticsConsent.GRANTED) + assertThat(store.observeAnalyticsConsent().first()).isEqualTo(AnalyticsConsent.GRANTED) + store.setAnalyticsConsent(AnalyticsConsent.DENIED) + assertThat(store.observeAnalyticsConsent().first()).isEqualTo(AnalyticsConsent.DENIED) + } +} +``` + +Note: `SettingsDataStoreImpl` is `internal`, so the test (same module) can construct it directly. + +- [ ] **Step 3: Run test to verify it fails** + +Run: `./gradlew :data-settings-datastore-public:test 2>&1 | grep -E "(BUILD|Test.*FAILED|error:)"` +Expected: compilation error, `observeAnalyticsConsent` unresolved. + +- [ ] **Step 4: Implement** + +In `SettingsDataStoreImpl.kt`, add to the `SettingsDataStore` interface: + +```kotlin + suspend fun setAnalyticsConsent(consent: AnalyticsConsent) + + /** + * The member's product analytics consent decision. [AnalyticsConsent.NOT_DECIDED] when they + * have not made an explicit choice yet. + */ + fun observeAnalyticsConsent(): Flow +``` + +Add to `SettingsDataStoreImpl`: + +```kotlin + override suspend fun setAnalyticsConsent(consent: AnalyticsConsent) { + dataStore.edit { + it[analyticsConsentKey] = consent.name + } + } + + override fun observeAnalyticsConsent(): Flow { + return dataStore.data.map { preferences -> + preferences[analyticsConsentKey] + ?.let { stored -> AnalyticsConsent.entries.firstOrNull { it.name == stored } } + ?: AnalyticsConsent.NOT_DECIDED + } + } +``` + +And in its `companion object`: + +```kotlin + private val analyticsConsentKey = stringPreferencesKey( + "com.hedvig.android.data.settings.datastore.settings-analytics-consent", + ) +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `./gradlew :data-settings-datastore-public:test 2>&1 | grep -E "(BUILD SUCCESSFUL|FAILED)"` +Expected: `BUILD SUCCESSFUL` + +- [ ] **Step 6: Format and commit** + +```bash +./gradlew ktlintFormat 2>&1 | grep -E "BUILD (SUCCESSFUL|FAILED)" +git add app/data/data-settings-datastore +git commit -m "Add tri-state AnalyticsConsent to SettingsDataStore" +``` + +--- + +### Task 2: Consent-aware Firebase tracking client (buffer-and-flush) + +**Files:** +- Modify: `app/tracking/tracking-firebase/src/main/kotlin/com/hedvig/android/tracking/firebase/FirebaseEventTrackingClient.kt` (remove `@ContributesBinding(AppScope::class)` only; keep `@SingleIn`, `@Inject`, `internal`) +- Create: `app/tracking/tracking-firebase/src/main/kotlin/com/hedvig/android/tracking/firebase/ConsentAwareEventTrackingClient.kt` +- Modify: `app/tracking/tracking-firebase/build.gradle.kts` +- Test: `app/tracking/tracking-firebase/src/test/kotlin/com/hedvig/android/tracking/firebase/ConsentAwareEventTrackingClientTest.kt` + +**Interfaces:** +- Consumes: `EventTrackingClient` (`com.hedvig.android.core.tracking`, methods `setCollectionEnabled`, `trackEvent(name, parameters)`, `trackScreen(name, screenClass, parameters)`, `setUserId`, `setUserProperty`), `SettingsDataStore.observeAnalyticsConsent()` from Task 1, `ApplicationScope` (`com.hedvig.android.core.common.ApplicationScope`). +- Produces: `ConsentAwareEventTrackingClient`, the sole `@ContributesBinding(AppScope::class)` for `EventTrackingClient`. Constructor: `(delegate: FirebaseEventTrackingClient, settingsDataStore: SettingsDataStore, applicationScope: ApplicationScope, @IoDispatcher coroutineContext: CoroutineContext)`. Behavior contract: NOT_DECIDED buffers events/screens (cap 200, oldest dropped), GRANTED flushes buffer (each replayed event gains a `buffered_at_epoch_ms` param) then forwards live, DENIED drops buffer and all subsequent events. `setUserId`/`setUserProperty`/`setCollectionEnabled` always pass through (demo-mode gating in `EventTrackingInitializer` keeps working unchanged). + +- [ ] **Step 1: Add deps to `tracking-firebase/build.gradle.kts`** + +Inside `dependencies {}` add: + +```kotlin +implementation(projects.dataSettingsDatastorePublic) +testImplementation(libs.assertK) +testImplementation(libs.coroutines.test) +testImplementation(libs.junit) +testImplementation(libs.turbine) +``` + +- [ ] **Step 2: Write the failing test** + +The test needs a testable seam for the consent flow and the delegate, so the class under test takes the `SettingsDataStore` interface (fakeable) and `EventTrackingClient` delegate typed parameter in a secondary visible-for-testing constructor. Simplest: make the primary constructor take the interface-typed delegate and have Metro inject the concrete one. Write exactly this: + +`ConsentAwareEventTrackingClientTest.kt`: + +```kotlin +package com.hedvig.android.tracking.firebase + +import assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo +import assertk.assertions.key +import com.hedvig.android.core.tracking.EventTrackingClient +import com.hedvig.android.data.settings.datastore.AnalyticsConsent +import com.hedvig.android.data.settings.datastore.SettingsDataStore +import com.hedvig.android.theme.Theme +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class ConsentAwareEventTrackingClientTest { + private class RecordingClient : EventTrackingClient { + val events = mutableListOf>>() + val screens = mutableListOf() + var userId: String? = null + + override fun setCollectionEnabled(enabled: Boolean) {} + + override fun trackEvent(name: String, parameters: Map) { + events.add(name to parameters) + } + + override fun trackScreen(name: String, screenClass: String?, parameters: Map) { + screens.add(name) + } + + override fun setUserId(userId: String?) { + this.userId = userId + } + + override fun setUserProperty(name: String, value: String?) {} + } + + private class FakeSettingsDataStore( + val consent: MutableStateFlow = MutableStateFlow(AnalyticsConsent.NOT_DECIDED), + ) : SettingsDataStore { + override suspend fun setTheme(theme: Theme) = error("unused") + + override fun observeTheme(): Flow = error("unused") + + override suspend fun setEmailSubscriptionPreference(subscribe: Boolean) = error("unused") + + override fun observeEmailSubscriptionPreference(): Flow = error("unused") + + override suspend fun setAnalyticsConsent(newConsent: AnalyticsConsent) { + consent.value = newConsent + } + + override fun observeAnalyticsConsent(): Flow = consent + } + + private fun TestScope.client( + recording: RecordingClient, + settings: FakeSettingsDataStore, + ): ConsentAwareEventTrackingClient { + return ConsentAwareEventTrackingClient( + delegate = recording, + settingsDataStore = settings, + applicationScope = backgroundScope, + coroutineContext = StandardTestDispatcher(testScheduler), + clock = { 1234L }, + ) + } + + @Test + fun `while NOT_DECIDED events are buffered, not forwarded`() = runTest { + val recording = RecordingClient() + val client = client(recording, FakeSettingsDataStore()) + runCurrent() + client.trackEvent("clicked_thing", mapOf("k" to "v")) + runCurrent() + assertThat(recording.events).isEmpty() + } + + @Test + fun `granting consent flushes buffered events with buffered_at_epoch_ms and forwards live ones`() = runTest { + val recording = RecordingClient() + val settings = FakeSettingsDataStore() + val client = client(recording, settings) + runCurrent() + client.trackEvent("early_event", emptyMap()) + settings.consent.value = AnalyticsConsent.GRANTED + runCurrent() + assertThat(recording.events.map { it.first }).containsExactly("early_event") + assertThat(recording.events.single().second).key("buffered_at_epoch_ms").isEqualTo(1234L) + client.trackEvent("live_event", emptyMap()) + assertThat(recording.events.map { it.first }).containsExactly("early_event", "live_event") + } + + @Test + fun `denying consent drops the buffer and all subsequent events`() = runTest { + val recording = RecordingClient() + val settings = FakeSettingsDataStore() + val client = client(recording, settings) + runCurrent() + client.trackEvent("early_event", emptyMap()) + settings.consent.value = AnalyticsConsent.DENIED + runCurrent() + client.trackEvent("post_denial_event", emptyMap()) + runCurrent() + assertThat(recording.events).isEmpty() + } + + @Test + fun `setUserId passes through regardless of consent`() = runTest { + val recording = RecordingClient() + val client = client(recording, FakeSettingsDataStore()) + runCurrent() + client.setUserId("member-id") + assertThat(recording.userId).isEqualTo("member-id") + } +} +``` + +Note: `FakeSettingsDataStore` must override every `SettingsDataStore` member; if Task 3 has already added members by the time this compiles, add `error("unused")` overrides for those too. The `applicationScope` parameter is typed `CoroutineScope` in the constructor below, so passing `backgroundScope` works; production wiring passes `ApplicationScope` (which is a `CoroutineScope`). + +- [ ] **Step 3: Run test to verify it fails** + +Run: `./gradlew :tracking-firebase:testDebugUnitTest 2>&1 | grep -E "(BUILD|error:)" | head -20` +Expected: compilation failure, `ConsentAwareEventTrackingClient` unresolved. + +- [ ] **Step 4: Implement** + +First, in `FirebaseEventTrackingClient.kt`, delete the line `@ContributesBinding(AppScope::class)` and its now-unused import. Keep `@SingleIn(AppScope::class)` and `@Inject`. + +`ConsentAwareEventTrackingClient.kt`: + +```kotlin +package com.hedvig.android.tracking.firebase + +import com.hedvig.android.core.common.di.AppScope +import com.hedvig.android.core.common.di.IoDispatcher +import com.hedvig.android.core.tracking.EventTrackingClient +import com.hedvig.android.data.settings.datastore.AnalyticsConsent +import com.hedvig.android.data.settings.datastore.SettingsDataStore +import com.hedvig.android.logger.logcat +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** + * Gates product analytics behind the member's [AnalyticsConsent]: + * - [AnalyticsConsent.NOT_DECIDED]: events/screens are buffered in-memory (bounded), not forwarded. + * - [AnalyticsConsent.GRANTED]: the buffer is flushed (stamped with `buffered_at_epoch_ms`) and + * subsequent events forward live. + * - [AnalyticsConsent.DENIED]: the buffer is dropped and nothing is forwarded. + * Identity calls ([setUserId], [setUserProperty]) and [setCollectionEnabled] (demo-mode gating) + * always pass through. Datadog is untouched by design: it carries performance and bug analytics, + * not product analytics. + * + * Deliberately NOT annotated with Metro annotations: annotating it `@ContributesBinding` for + * [EventTrackingClient] while also injecting an [EventTrackingClient] delegate would self-loop. + * The binding is supplied by [ConsentAwareEventTrackingClientProviders] below, which hands in the + * concrete [FirebaseEventTrackingClient] as the delegate. + */ +internal class ConsentAwareEventTrackingClient( + private val delegate: EventTrackingClient, + private val settingsDataStore: SettingsDataStore, + applicationScope: CoroutineScope, + @IoDispatcher coroutineContext: CoroutineContext, + private val clock: () -> Long = { System.currentTimeMillis() }, +) : EventTrackingClient { + private val lock = Any() + private var consent: AnalyticsConsent = AnalyticsConsent.NOT_DECIDED + private val buffer = ArrayDeque() + + init { + applicationScope.launch(coroutineContext) { + settingsDataStore.observeAnalyticsConsent().collect { newConsent -> + val toFlush: List = synchronized(lock) { + consent = newConsent + when (newConsent) { + AnalyticsConsent.GRANTED -> buffer.toList().also { buffer.clear() } + AnalyticsConsent.DENIED -> { + buffer.clear() + emptyList() + } + AnalyticsConsent.NOT_DECIDED -> emptyList() + } + } + if (toFlush.isNotEmpty()) { + logcat { "Analytics consent granted, flushing ${toFlush.size} buffered events" } + for (call in toFlush) { + call.forwardTo(delegate) + } + } + } + } + } + + override fun setCollectionEnabled(enabled: Boolean) { + delegate.setCollectionEnabled(enabled) + } + + override fun trackEvent(name: String, parameters: Map) { + handle(BufferedCall.Event(name, parameters, clock())) + } + + override fun trackScreen(name: String, screenClass: String?, parameters: Map) { + handle(BufferedCall.Screen(name, screenClass, parameters, clock())) + } + + override fun setUserId(userId: String?) { + delegate.setUserId(userId) + } + + override fun setUserProperty(name: String, value: String?) { + delegate.setUserProperty(name, value) + } + + private fun handle(call: BufferedCall) { + val action: Action = synchronized(lock) { + when (consent) { + AnalyticsConsent.GRANTED -> Action.Forward + AnalyticsConsent.DENIED -> Action.Drop + AnalyticsConsent.NOT_DECIDED -> { + buffer.addLast(call) + while (buffer.size > MAX_BUFFERED_CALLS) { + buffer.removeFirst() + } + Action.Drop + } + } + } + if (action == Action.Forward) { + call.forwardTo(delegate, includeBufferedAt = false) + } + } + + private enum class Action { Forward, Drop } + + private sealed interface BufferedCall { + val bufferedAtEpochMs: Long + + fun forwardTo(client: EventTrackingClient, includeBufferedAt: Boolean = true) + + data class Event( + val name: String, + val parameters: Map, + override val bufferedAtEpochMs: Long, + ) : BufferedCall { + override fun forwardTo(client: EventTrackingClient, includeBufferedAt: Boolean) { + val params = if (includeBufferedAt) parameters + ("buffered_at_epoch_ms" to bufferedAtEpochMs) else parameters + client.trackEvent(name, params) + } + } + + data class Screen( + val name: String, + val screenClass: String?, + val parameters: Map, + override val bufferedAtEpochMs: Long, + ) : BufferedCall { + override fun forwardTo(client: EventTrackingClient, includeBufferedAt: Boolean) { + val params = if (includeBufferedAt) parameters + ("buffered_at_epoch_ms" to bufferedAtEpochMs) else parameters + client.trackScreen(name, screenClass, params) + } + } + } + + companion object { + private const val MAX_BUFFERED_CALLS = 200 + } +} +``` + +In the same file, below the class, add the providers interface that supplies the binding (adjust the file's import list accordingly: `dev.zacsweers.metro.ContributesTo`, `dev.zacsweers.metro.Provides`, `com.hedvig.android.core.common.ApplicationScope`; remove the now-unused `dev.zacsweers.metro.Inject` / `dev.zacsweers.metro.ContributesBinding` imports if present): + +```kotlin +@ContributesTo(AppScope::class) +internal interface ConsentAwareEventTrackingClientProviders { + @Provides + @SingleIn(AppScope::class) + fun provideEventTrackingClient( + firebaseEventTrackingClient: FirebaseEventTrackingClient, + settingsDataStore: SettingsDataStore, + applicationScope: ApplicationScope, + @IoDispatcher coroutineContext: CoroutineContext, + ): EventTrackingClient = ConsentAwareEventTrackingClient( + delegate = firebaseEventTrackingClient, + settingsDataStore = settingsDataStore, + applicationScope = applicationScope, + coroutineContext = coroutineContext, + ) +} +``` + +The class keeps the default-argument `clock` parameter so tests can pin time; production wiring never passes it. + +- [ ] **Step 5: Run tests** + +Run: `./gradlew :tracking-firebase:testDebugUnitTest 2>&1 | grep -E "(BUILD SUCCESSFUL|FAILED|error:)"` +Expected: `BUILD SUCCESSFUL` + +- [ ] **Step 6: Verify the app graph still compiles (binding swap is graph-visible)** + +Run: `./gradlew :app:compileDevelopDebugKotlin 2>&1 | grep -E "(BUILD SUCCESSFUL|FAILED|error:)" | head -5` +Expected: `BUILD SUCCESSFUL`. If Metro reports a duplicate or missing `EventTrackingClient` binding, re-check that `FirebaseEventTrackingClient` no longer has `@ContributesBinding` and only the `@Provides` above supplies the interface. +(If `:app:compileDevelopDebugKotlin` is not a valid task name, list variants with `./gradlew :app:tasks --all | grep -i compile | head` and use the develop-debug variant.) + +- [ ] **Step 7: Format and commit** + +```bash +./gradlew ktlintFormat 2>&1 | grep -E "BUILD (SUCCESSFUL|FAILED)" +git add app/tracking/tracking-firebase +git commit -m "Gate Firebase Analytics behind tri-state consent with buffer-and-flush" +``` + +--- + +### Task 3: Analytics consent toggle row in profile Settings + +**Files:** +- Modify: `app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/settings/SettingsPresenter.kt` +- Modify: `app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/settings/SettingsDestination.kt` + +**Interfaces:** +- Consumes: `SettingsDataStore.observeAnalyticsConsent()` / `setAnalyticsConsent(...)` (Task 1), existing `SettingsUiState`/`SettingsEvent` shapes, `HedvigBigCard(onClick, inputText, labelText, modifier)` (used verbatim by the existing Notifications row). +- Produces: `SettingsEvent.ChangeAnalyticsConsent(val consent: AnalyticsConsent)`, `SettingsUiState.analyticsConsent: AnalyticsConsent?` on both `Loading` (always null) and `Loaded`. + +No new presenter test: `SettingsPresenter` has a wide constructor and this change is two straight datastore pass-throughs; the datastore behavior itself is covered by Task 1's tests. Compile plus existing module tests gate this task. + +- [ ] **Step 1: Extend UiState and Events** + +In `SettingsPresenter.kt`: + +- Add import `com.hedvig.android.data.settings.datastore.AnalyticsConsent`. +- In `sealed interface SettingsUiState` add `val analyticsConsent: AnalyticsConsent?` to the interface; in `Loading` add `override val analyticsConsent: AnalyticsConsent? = null`; in `Loaded` add `override val analyticsConsent: AnalyticsConsent?` as a constructor property. +- In `sealed interface SettingsEvent` add: + +```kotlin + data class ChangeAnalyticsConsent(val consent: AnalyticsConsent) : SettingsEvent +``` + +- [ ] **Step 2: Wire the presenter** + +In `SettingsPresenter.present(...)`, next to the other `collectAsState` lines: + +```kotlin + val analyticsConsent = settingsDataStore.observeAnalyticsConsent() + .collectAsState(lastState.analyticsConsent).value +``` + +In `CollectEvents`' `when` add: + +```kotlin + is SettingsEvent.ChangeAnalyticsConsent -> { + launch { settingsDataStore.setAnalyticsConsent(event.consent) } + } +``` + +Pass `analyticsConsent = analyticsConsent` into the returned `SettingsUiState.Loaded(...)` construction. + +- [ ] **Step 3: Add the row to the settings screen** + +In `SettingsDestination.kt`, inside the `Loaded` branch after the `EmailSubscriptionWithDialog` block (and its trailing spacer), add a row following the existing Notifications-row pattern: + +```kotlin + Spacer(Modifier.height(4.dp)) + HedvigBigCard( + onClick = { + val newConsent = if (uiState.analyticsConsent == AnalyticsConsent.GRANTED) { + AnalyticsConsent.DENIED + } else { + AnalyticsConsent.GRANTED + } + onChangeAnalyticsConsent(newConsent) + }, + inputText = if (uiState.analyticsConsent == AnalyticsConsent.GRANTED) { + // TODO: Add "On" / "På" to Lokalise + "On" + } else { + // NOT_DECIDED renders as "Off": nothing is forwarded to Firebase in that state. + // TODO: Add "Off" / "Av" to Lokalise + "Off" + }, + // TODO: Add "Product analytics" / "Produktanalys" to Lokalise + labelText = "Product analytics", + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) +``` + +Thread a new `onChangeAnalyticsConsent: (AnalyticsConsent) -> Unit` parameter through `SettingsScreen(...)` from `SettingsDestination(...)`'s viewModel call site: `onChangeAnalyticsConsent = { viewModel.emit(SettingsEvent.ChangeAnalyticsConsent(it)) }`. Match how `changeSubscriptionPreference` is threaded today. Add the `AnalyticsConsent` import. + +- [ ] **Step 4: Compile and test the module** + +Run: `./gradlew :feature-profile:testDebugUnitTest 2>&1 | grep -E "(BUILD SUCCESSFUL|FAILED|error:)" | head -5` +Expected: `BUILD SUCCESSFUL`. If an existing `SettingsPresenterTest` constructs `SettingsUiState.Loaded`, add `analyticsConsent = null` to those constructor calls. + +- [ ] **Step 5: Format and commit** + +```bash +./gradlew ktlintFormat 2>&1 | grep -E "BUILD (SUCCESSFUL|FAILED)" +git add app/feature/feature-profile +git commit -m "Add product analytics consent toggle to settings" +``` + +--- + +### Task 4: feature-onboarding module scaffold and nav keys + +**Files:** +- Create: `app/feature/feature-onboarding/build.gradle.kts` +- Create: `app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingKeys.kt` + +**Interfaces:** +- Produces: `OnboardingKey` (public `data object`, the flow root/welcome), `OnboardingStepId` (public enum: `AnalyticsConsent, PhoneNumber, Theme, CoInsured, PetIds, InviteFriend, ConnectPayment, BundleDiscount`), `OnboardingStepKey(stepId: OnboardingStepId)` (internal). All later tasks use these exact names. Note: `Welcome` is NOT an `OnboardingStepId`; the welcome screen is `OnboardingKey` itself. The progress bar counts welcome as position 0 (see Task 6's path model). + +The module is auto-discovered by `settings.gradle.kts` (any dir under `app/` with a `build.gradle.kts`). + +- [ ] **Step 1: Create `build.gradle.kts`** + +```kotlin +plugins { + id("hedvig.android.library") + id("hedvig.gradle.plugin") +} + +hedvig { + apollo("octopus") + serialization() + compose() + navKeys() + viewModels() +} + +dependencies { + implementation(libs.apollo.runtime) + implementation(libs.arrow.core) + implementation(libs.coroutines.core) + implementation(libs.androidx.datastore.core) + implementation(libs.androidx.datastore.preferencesCore) + implementation(libs.jetbrains.lifecycle.runtime.compose) + implementation(libs.kotlinx.serialization.core) + implementation(projects.apolloCore) + implementation(projects.apolloOctopusPublic) + implementation(projects.authCorePublic) + implementation(projects.coreCommonPublic) + implementation(projects.coreResources) + implementation(projects.dataSettingsDatastorePublic) + implementation(projects.designSystemHedvig) + implementation(projects.featureConnectPaymentTrustlyNavigation) + implementation(projects.featureEditCoinsuredNavigation) + implementation(projects.moleculePublic) + implementation(projects.navigationCommon) + implementation(projects.navigationCompose) + implementation(projects.theme) + + testImplementation(libs.assertK) + testImplementation(libs.coroutines.test) + testImplementation(libs.junit) + testImplementation(libs.turbine) + testImplementation(projects.moleculeTest) +} +``` + +(Modeled on `app/feature/feature-chip-id/build.gradle.kts`. `ChipIdKey` is deliberately absent: `feature-chip-id` is not a `-navigation` module, so navigation to it is threaded from `:app` as a lambda in Task 19.) + +- [ ] **Step 2: Create the keys** + +`OnboardingKeys.kt`: + +```kotlin +package com.hedvig.android.feature.onboarding.navigation + +import com.hedvig.android.navigation.common.HedvigNavKey +import kotlinx.serialization.Serializable + +/** + * The onboarding flow root, rendering the welcome step. Pushed on top of Home by the onboarding + * gate in `:app` for members who have not seen onboarding yet. + */ +@androidx.annotation.Keep +@Serializable +data object OnboardingKey : HedvigNavKey + +/** + * One entry per onboarding step after welcome. Deliberately tiny: the eagerly-fetched flow data + * lives in the ActivityRetainedScope OnboardingSessionStore, not in the key, so back-stack + * persistence stays cheap and process-death restore re-fetches instead of deserializing a blob. + */ +@androidx.annotation.Keep +@Serializable +internal data class OnboardingStepKey( + val stepId: OnboardingStepId, +) : HedvigNavKey + +/** + * Identifies each onboarding step past welcome. The member's concrete path (which of these apply, + * in order) is computed by buildOnboardingPath from the eagerly fetched OnboardingData. + */ +enum class OnboardingStepId { + AnalyticsConsent, + PhoneNumber, + Theme, + CoInsured, + PetIds, + InviteFriend, + ConnectPayment, + BundleDiscount, +} +``` + +- [ ] **Step 3: Verify the module compiles** + +Run: `./gradlew :feature-onboarding:compileDebugKotlin 2>&1 | grep -E "(BUILD SUCCESSFUL|FAILED|error:)" | head -5` +Expected: `BUILD SUCCESSFUL`. + +Note: `ExhaustiveBackStackSerializationTest` in `:app` discovers keys by classpath scanning; these keys only enter its scope when `:app` gains the dependency in Task 19, so no action here. + +- [ ] **Step 4: Format and commit** + +```bash +./gradlew ktlintFormat 2>&1 | grep -E "BUILD (SUCCESSFUL|FAILED)" +git add app/feature/feature-onboarding +git commit -m "Scaffold feature-onboarding module with nav keys" +``` + +--- + +### Task 5: OnboardingQuery, project-owned data models, repository + +**Files:** +- Create: `app/feature/feature-onboarding/src/main/graphql/OnboardingQuery.graphql` +- Create: `app/feature/feature-onboarding/src/main/graphql/OnboardingUpdateContactInfoMutation.graphql` +- Create: `app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingData.kt` +- Create: `app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingRepository.kt` + +**Interfaces:** +- Consumes: `ApolloClient` (unqualified, `AppScope`), `safeExecute(::ErrorMessage)` from `com.hedvig.android.apollo`, `ErrorMessage` from `com.hedvig.android.core.common`. +- Produces: + - `OnboardingData(email, phoneNumber, contracts, referralInformation, hasConnectedPayinMethod, crossSells)` with derived `contractsWithMissingCoInsured`, `contractsWithMissingPetId`, `hasOnlyAccidentContracts`. + - `OnboardingContract(id, displayName, exposureName, typeOfContract, missingCoInsuredCount, isMissingPetId)`. + - `OnboardingReferralInformation(code, monthlyDiscountPerReferralAmount: Double, currencyCode: String)`. + - `OnboardingCrossSell(id, title, description, storeUrl)`. + - `OnboardingRepository` with `suspend fun getOnboardingData(): Either` and `suspend fun updateContactInfo(email: String, phoneNumber: String): Either`. + +All types `internal`; no `octopus.*` type escapes the repository impl. Derivation logic (missing filters, accident-only) lives on `OnboardingData` as pure code so Task 6's tests cover it without Apollo types. + +- [ ] **Step 1: Write the query** + +`OnboardingQuery.graphql`: + +```graphql +query Onboarding { + currentMember { + id + email + phoneNumber + activeContracts { + id + exposureDisplayNameShort + isMissingPetId + coInsured { + hasMissingInfo + } + currentAgreement { + productVariant { + typeOfContract + displayName + } + } + } + referralInformation { + code + monthlyDiscountPerReferral { + amount + currencyCode + } + } + paymentMethods { + payinMethods { + status + } + } + crossSellV2(input: { userFlow: HOME_X_SELL, flowSource: null, experiments: [] }) { + otherCrossSells { + id + title + description + storeUrl + } + } + } +} +``` + +(`userFlow: HOME_X_SELL` mirrors `feature-home`'s existing `crossSellV2` usage; onboarding is entered from Home. If codegen complains about a field's exact shape, check the schema at `app/apollo/apollo-octopus-public/src/commonMain/graphql/com/hedvig/android/apollo/octopus/schema.graphqls` and adjust the selection, never the Kotlin-facing model shapes.) + +`OnboardingUpdateContactInfoMutation.graphql`: + +```graphql +mutation OnboardingUpdateContactInfo($email: String!, $phoneNumber: String!) { + memberUpdateContactInfo(input: { email: $email, phoneNumber: $phoneNumber }) { + userError { + message + } + member { + id + phoneNumber + } + } +} +``` + +- [ ] **Step 2: Create the models** + +`OnboardingData.kt`: + +```kotlin +package com.hedvig.android.feature.onboarding.data + +/** + * Everything the onboarding flow needs, fetched eagerly in one round trip before the flow is + * shown, so the progress bar length is known up-front and no step blocks on network to render. + */ +internal data class OnboardingData( + val email: String, + val phoneNumber: String?, + val contracts: List, + val referralInformation: OnboardingReferralInformation?, + val hasConnectedPayinMethod: Boolean, + val crossSells: List, +) { + val contractsWithMissingCoInsured: List = + contracts.filter { it.missingCoInsuredCount > 0 } + + val contractsWithMissingPetId: List = + contracts.filter { it.isMissingPetId } + + val hasOnlyAccidentContracts: Boolean = + contracts.isNotEmpty() && contracts.all { it.typeOfContract.contains("ACCIDENT") } +} + +internal data class OnboardingContract( + val id: String, + val displayName: String, + val exposureName: String, + val typeOfContract: String, + val missingCoInsuredCount: Int, + val isMissingPetId: Boolean, +) + +internal data class OnboardingReferralInformation( + val code: String, + val monthlyDiscountPerReferralAmount: Double, + val currencyCode: String, +) + +internal data class OnboardingCrossSell( + val id: String, + val title: String, + val description: String, + val storeUrl: String, +) +``` + +- [ ] **Step 3: Create the repository** + +`OnboardingRepository.kt`: + +```kotlin +package com.hedvig.android.feature.onboarding.data + +import arrow.core.Either +import arrow.core.raise.either +import arrow.core.raise.ensure +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.cache.normalized.FetchPolicy +import com.apollographql.apollo.cache.normalized.fetchPolicy +import com.hedvig.android.apollo.safeExecute +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.core.common.di.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import octopus.OnboardingQuery +import octopus.OnboardingUpdateContactInfoMutation + +internal interface OnboardingRepository { + suspend fun getOnboardingData(): Either + + suspend fun updateContactInfo(email: String, phoneNumber: String): Either +} + +@ContributesBinding(AppScope::class) +@Inject +internal class OnboardingRepositoryImpl( + private val apolloClient: ApolloClient, +) : OnboardingRepository { + override suspend fun getOnboardingData(): Either = either { + val member = apolloClient + .query(OnboardingQuery()) + .fetchPolicy(FetchPolicy.NetworkOnly) + .safeExecute(::ErrorMessage) + .bind() + .currentMember + OnboardingData( + email = member.email, + phoneNumber = member.phoneNumber, + contracts = member.activeContracts.map { contract -> + OnboardingContract( + id = contract.id, + displayName = contract.currentAgreement.productVariant.displayName, + exposureName = contract.exposureDisplayNameShort, + typeOfContract = contract.currentAgreement.productVariant.typeOfContract, + missingCoInsuredCount = contract.coInsured.orEmpty().count { it.hasMissingInfo }, + isMissingPetId = contract.isMissingPetId, + ) + }, + referralInformation = member.referralInformation.let { referralInformation -> + OnboardingReferralInformation( + code = referralInformation.code, + monthlyDiscountPerReferralAmount = referralInformation.monthlyDiscountPerReferral.amount, + currencyCode = referralInformation.monthlyDiscountPerReferral.currencyCode.rawValue, + ) + }, + hasConnectedPayinMethod = member.paymentMethods.payinMethods.isNotEmpty(), + crossSells = member.crossSellV2.otherCrossSells.map { crossSell -> + OnboardingCrossSell( + id = crossSell.id, + title = crossSell.title, + description = crossSell.description, + storeUrl = crossSell.storeUrl, + ) + }, + ) + } + + override suspend fun updateContactInfo(email: String, phoneNumber: String): Either = either { + val result = apolloClient + .mutation(OnboardingUpdateContactInfoMutation(email = email, phoneNumber = phoneNumber)) + .safeExecute(::ErrorMessage) + .bind() + val userError = result.memberUpdateContactInfo.userError + ensure(userError == null) { ErrorMessage(userError?.message) } + } +} +``` + +Adjust generated accessor shapes to whatever `generateApolloSources` produces (nullability of `id` as `UUID`/String, `currencyCode.rawValue` vs `.name`, `contract.id` may need `.toString()` since the schema type is `UUID!`). The rule: fix the impl's mapping lines, never leak the generated types past this file. If `referralInformation` turns out nullable in codegen, map null to `referralInformation = null`. + +- [ ] **Step 4: Generate Apollo sources and compile** + +Run: `./gradlew :feature-onboarding:generateApolloSources :feature-onboarding:compileDebugKotlin 2>&1 | grep -E "(BUILD SUCCESSFUL|FAILED|error:|e:)" | head -20` +Expected: `BUILD SUCCESSFUL`. Iterate on the mapping lines per the note above until clean. + +- [ ] **Step 5: Format and commit** + +```bash +./gradlew ktlintFormat 2>&1 | grep -E "BUILD (SUCCESSFUL|FAILED)" +git add app/feature/feature-onboarding +git commit -m "Add onboarding query, data models and repository" +``` + +--- + +### Task 6: buildOnboardingPath pure function + +**Files:** +- Create: `app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingPath.kt` +- Test: `app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingPathTest.kt` + +**Interfaces:** +- Consumes: `OnboardingData` (Task 5), `OnboardingStepId` (Task 4). +- Produces: `internal fun buildOnboardingPath(data: OnboardingData): List`. Welcome is not in the list (it's `OnboardingKey`); the progress bar's total segment count is `path.size + 1` and a step's 0-based position is `path.indexOf(stepId) + 1` (welcome occupies position 0). Tasks 8-19 rely on this convention. + +- [ ] **Step 1: Write the failing tests** + +`OnboardingPathTest.kt`: + +```kotlin +package com.hedvig.android.feature.onboarding.data + +import assertk.assertThat +import assertk.assertions.contains +import assertk.assertions.containsExactly +import assertk.assertions.doesNotContain +import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId +import org.junit.Test + +class OnboardingPathTest { + private fun contract( + id: String = "contract-1", + typeOfContract: String = "SE_APARTMENT_RENT", + missingCoInsuredCount: Int = 0, + isMissingPetId: Boolean = false, + ) = OnboardingContract( + id = id, + displayName = "Home Insurance", + exposureName = "Bellmansgatan 19A", + typeOfContract = typeOfContract, + missingCoInsuredCount = missingCoInsuredCount, + isMissingPetId = isMissingPetId, + ) + + private fun data( + contracts: List = listOf(contract()), + referralInformation: OnboardingReferralInformation? = OnboardingReferralInformation("CODE", 10.0, "SEK"), + hasConnectedPayinMethod: Boolean = false, + crossSells: List = listOf(OnboardingCrossSell("cs", "Pet", "For your pet", "https://x")), + ) = OnboardingData( + email = "member@example.com", + phoneNumber = "070 990 12 32", + contracts = contracts, + referralInformation = referralInformation, + hasConnectedPayinMethod = hasConnectedPayinMethod, + crossSells = crossSells, + ) + + @Test + fun `consent, phone and theme are always present, in order, at the front`() { + val path = buildOnboardingPath(data()) + assertThat(path.take(3)).containsExactly( + OnboardingStepId.AnalyticsConsent, + OnboardingStepId.PhoneNumber, + OnboardingStepId.Theme, + ) + } + + @Test + fun `everything applicable produces the full path in canonical order`() { + val path = buildOnboardingPath( + data(contracts = listOf(contract(missingCoInsuredCount = 1, isMissingPetId = true))), + ) + assertThat(path).containsExactly( + OnboardingStepId.AnalyticsConsent, + OnboardingStepId.PhoneNumber, + OnboardingStepId.Theme, + OnboardingStepId.CoInsured, + OnboardingStepId.PetIds, + OnboardingStepId.InviteFriend, + OnboardingStepId.ConnectPayment, + OnboardingStepId.BundleDiscount, + ) + } + + @Test + fun `co-insured step is skipped when no contract has missing co-insured info`() { + val path = buildOnboardingPath(data(contracts = listOf(contract(missingCoInsuredCount = 0)))) + assertThat(path).doesNotContain(OnboardingStepId.CoInsured) + } + + @Test + fun `pet id step is skipped when no contract is missing a pet id`() { + val path = buildOnboardingPath(data(contracts = listOf(contract(isMissingPetId = false)))) + assertThat(path).doesNotContain(OnboardingStepId.PetIds) + } + + @Test + fun `invite step is skipped without referral information`() { + val path = buildOnboardingPath(data(referralInformation = null)) + assertThat(path).doesNotContain(OnboardingStepId.InviteFriend) + } + + @Test + fun `connect payment step is skipped when a payin method is already connected`() { + val path = buildOnboardingPath(data(hasConnectedPayinMethod = true)) + assertThat(path).doesNotContain(OnboardingStepId.ConnectPayment) + } + + @Test + fun `bundle step is skipped when there are no cross sells`() { + val path = buildOnboardingPath(data(crossSells = emptyList())) + assertThat(path).doesNotContain(OnboardingStepId.BundleDiscount) + } + + @Test + fun `bundle step is skipped for accident-only members even with cross sells`() { + val path = buildOnboardingPath(data(contracts = listOf(contract(typeOfContract = "SE_ACCIDENT")))) + assertThat(path).doesNotContain(OnboardingStepId.BundleDiscount) + } + + @Test + fun `bundle step is present for a mixed portfolio that includes accident`() { + val path = buildOnboardingPath( + data(contracts = listOf(contract(typeOfContract = "SE_ACCIDENT"), contract(id = "c2"))), + ) + assertThat(path).contains(OnboardingStepId.BundleDiscount) + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `./gradlew :feature-onboarding:testDebugUnitTest 2>&1 | grep -E "(BUILD|error:)" | head -5` +Expected: compile failure, `buildOnboardingPath` unresolved. + +- [ ] **Step 3: Implement** + +`OnboardingPath.kt`: + +```kotlin +package com.hedvig.android.feature.onboarding.data + +import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId + +/** + * Computes which onboarding steps apply to this member, in presentation order. Pure so the skip + * rules stay unit-testable. Welcome is not in the result: it is the flow root itself, occupying + * progress position 0, so the progress bar renders `size + 1` segments. + */ +internal fun buildOnboardingPath(data: OnboardingData): List = buildList { + add(OnboardingStepId.AnalyticsConsent) + add(OnboardingStepId.PhoneNumber) + add(OnboardingStepId.Theme) + if (data.contractsWithMissingCoInsured.isNotEmpty()) { + add(OnboardingStepId.CoInsured) + } + if (data.contractsWithMissingPetId.isNotEmpty()) { + add(OnboardingStepId.PetIds) + } + if (data.referralInformation != null) { + add(OnboardingStepId.InviteFriend) + } + if (!data.hasConnectedPayinMethod) { + add(OnboardingStepId.ConnectPayment) + } + if (data.crossSells.isNotEmpty() && !data.hasOnlyAccidentContracts) { + add(OnboardingStepId.BundleDiscount) + } +} +``` + +- [ ] **Step 4: Run tests** + +Run: `./gradlew :feature-onboarding:testDebugUnitTest 2>&1 | grep -E "(BUILD SUCCESSFUL|FAILED)"` +Expected: `BUILD SUCCESSFUL` + +- [ ] **Step 5: Format and commit** + +```bash +./gradlew ktlintFormat 2>&1 | grep -E "BUILD (SUCCESSFUL|FAILED)" +git add app/feature/feature-onboarding +git commit -m "Add onboarding path builder with per-step skip rules" +``` + +--- + +### Task 7: OnboardingSeenStore (per-member show-once flag) + +**Files:** +- Create: `app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingSeenStore.kt` +- Test: `app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingSeenStoreTest.kt` + +**Interfaces:** +- Consumes: `DataStore` (AppScope binding from `core-datastore-public`). +- Produces: `internal interface OnboardingSeenStore { suspend fun hasSeenOnboarding(memberId: String): Boolean; suspend fun markOnboardingSeen(memberId: String) }`. Member-id resolution is deliberately the caller's job (Tasks 8/19 hold `MemberIdService`), keeping this store pure and testable. + +- [ ] **Step 1: Write the failing test** + +`OnboardingSeenStoreTest.kt`: + +```kotlin +package com.hedvig.android.feature.onboarding.data + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import assertk.assertThat +import assertk.assertions.isFalse +import assertk.assertions.isTrue +import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toPath +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class OnboardingSeenStoreTest { + @get:Rule + val temporaryFolder: TemporaryFolder = TemporaryFolder() + + // Note: the path must NOT exist yet; DataStore treats a pre-created empty file as corrupt. + private fun store(): OnboardingSeenStore = DataStoreOnboardingSeenStore( + PreferenceDataStoreFactory.createWithPath( + produceFile = { File(temporaryFolder.root, "seen.preferences_pb").absolutePath.toPath() }, + ), + ) + + @Test + fun `a member who never saw onboarding reads false`() = runTest { + assertThat(store().hasSeenOnboarding("123")).isFalse() + } + + @Test + fun `marking seen is per member`() = runTest { + val store = store() + store.markOnboardingSeen("123") + assertThat(store.hasSeenOnboarding("123")).isTrue() + assertThat(store.hasSeenOnboarding("456")).isFalse() + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `./gradlew :feature-onboarding:testDebugUnitTest 2>&1 | grep -E "(BUILD|error:)" | head -5` +Expected: compile failure. + +- [ ] **Step 3: Implement** + +`OnboardingSeenStore.kt`: + +```kotlin +package com.hedvig.android.feature.onboarding.data + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import com.hedvig.android.core.common.di.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +/** + * Remembers, per member and per install, that onboarding was shown. Set when the member completes + * the flow or dismisses it with the close button; once set, onboarding never appears again for + * that member on this device. + */ +internal interface OnboardingSeenStore { + suspend fun hasSeenOnboarding(memberId: String): Boolean + + suspend fun markOnboardingSeen(memberId: String) +} + +@ContributesBinding(AppScope::class) +@SingleIn(AppScope::class) +@Inject +internal class DataStoreOnboardingSeenStore( + private val dataStore: DataStore, +) : OnboardingSeenStore { + override suspend fun hasSeenOnboarding(memberId: String): Boolean { + return dataStore.data.map { it[seenKey(memberId)] ?: false }.first() + } + + override suspend fun markOnboardingSeen(memberId: String) { + dataStore.edit { it[seenKey(memberId)] = true } + } + + private fun seenKey(memberId: String): Preferences.Key { + return booleanPreferencesKey("com.hedvig.android.feature.onboarding.seen.$memberId") + } +} +``` + +- [ ] **Step 4: Run tests, format, commit** + +```bash +./gradlew :feature-onboarding:testDebugUnitTest 2>&1 | grep -E "(BUILD SUCCESSFUL|FAILED)" +./gradlew ktlintFormat 2>&1 | grep -E "BUILD (SUCCESSFUL|FAILED)" +git add app/feature/feature-onboarding +git commit -m "Add per-member onboarding seen store" +``` + +--- + +### Task 8: OnboardingSessionStore, CompleteOnboardingUseCase, OnboardingNavigator + +**Files:** +- Create: `app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingSessionStore.kt` +- Create: `app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/CompleteOnboardingUseCase.kt` +- Create: `app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingNavigator.kt` +- Test: `app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingNavigatorTest.kt` +- Test fixture: `app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/FakeOnboardingRepository.kt` + +**Interfaces:** +- Consumes: `OnboardingRepository`, `buildOnboardingPath`, `OnboardingSeenStore`, `MemberIdService` (`com.hedvig.android.auth`), `Backstack` + extensions (`add`, `removeAllOf`, `navigateUp` from `com.hedvig.android.navigation.compose`). +- Produces (used by every step presenter and by Task 19's gate): + - `OnboardingSessionStore` (`@SingleIn(ActivityRetainedScope::class)`): `val currentSession: OnboardingSession?`; `suspend fun getOrFetchSession(): Either`; `suspend fun refreshData(): Either` (refetches data, KEEPS the original path so the progress bar never reshuffles mid-flow). + - `OnboardingSession(data: OnboardingData, path: List)`. + - `CompleteOnboardingUseCase` interface: `suspend fun invoke()` (resolves member id, marks seen; no-op if no member id). + - `OnboardingNavigator` (`@SingleIn(ActivityRetainedScope::class)`): `suspend fun continueFrom(current: OnboardingStepId?)` (null = from welcome; unknown/last step exits), `suspend fun exitOnboarding()` (marks seen then removes all onboarding keys), `fun navigateBack()` (delegates to `backstack.navigateUp()`, used only by the top-bar arrow). + +- [ ] **Step 1: Implement the session store** + +`OnboardingSessionStore.kt`: + +```kotlin +package com.hedvig.android.feature.onboarding.data + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.core.common.di.ActivityRetainedScope +import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Holds the eagerly fetched onboarding data plus the computed step path for the lifetime of one + * Activity. Keys stay tiny (see OnboardingStepKey); after process death this cache is empty and + * the visible step re-fetches through [getOrFetchSession]. + */ +@SingleIn(ActivityRetainedScope::class) +@Inject +internal class OnboardingSessionStore( + private val onboardingRepository: OnboardingRepository, +) { + private val mutex = Mutex() + private var cachedSession: OnboardingSession? = null + + val currentSession: OnboardingSession? + get() = cachedSession + + suspend fun getOrFetchSession(): Either = mutex.withLock { + cachedSession?.let { return@withLock it.right() } + onboardingRepository.getOnboardingData().map { data -> + OnboardingSession(data = data, path = buildOnboardingPath(data)).also { cachedSession = it } + } + } + + /** + * Refetches the data (used after returning from an external flow like edit co-insured or + * Trustly) but keeps the original path: the progress bar must not reshuffle mid-flow. + */ + suspend fun refreshData(): Either = mutex.withLock { + val existing = cachedSession + ?: return@withLock ErrorMessage("No onboarding session to refresh").left() + onboardingRepository.getOnboardingData().map { data -> + existing.copy(data = data).also { cachedSession = it } + } + } +} + +internal data class OnboardingSession( + val data: OnboardingData, + val path: List, +) +``` + +- [ ] **Step 2: Implement CompleteOnboardingUseCase** + +`CompleteOnboardingUseCase.kt`: + +```kotlin +package com.hedvig.android.feature.onboarding.data + +import com.hedvig.android.auth.MemberIdService +import com.hedvig.android.core.common.di.AppScope +import com.hedvig.android.logger.LogPriority +import com.hedvig.android.logger.logcat +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import kotlinx.coroutines.flow.first + +internal interface CompleteOnboardingUseCase { + suspend fun invoke() +} + +@ContributesBinding(AppScope::class) +@Inject +internal class CompleteOnboardingUseCaseImpl( + private val memberIdService: MemberIdService, + private val onboardingSeenStore: OnboardingSeenStore, +) : CompleteOnboardingUseCase { + override suspend fun invoke() { + val memberId = memberIdService.getMemberId().first() + if (memberId == null) { + logcat(LogPriority.WARN) { "Completing onboarding without a member id; seen flag not stored" } + return + } + onboardingSeenStore.markOnboardingSeen(memberId) + } +} +``` + +- [ ] **Step 3: Write the failing navigator test** + +`FakeOnboardingRepository.kt` (test sources; also used by later presenter tests): + +```kotlin +package com.hedvig.android.feature.onboarding + +import app.cash.turbine.Turbine +import arrow.core.Either +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.feature.onboarding.data.OnboardingContract +import com.hedvig.android.feature.onboarding.data.OnboardingCrossSell +import com.hedvig.android.feature.onboarding.data.OnboardingData +import com.hedvig.android.feature.onboarding.data.OnboardingReferralInformation +import com.hedvig.android.feature.onboarding.data.OnboardingRepository + +internal class FakeOnboardingRepository : OnboardingRepository { + val onboardingDataResponses = Turbine>() + val updateContactInfoResponses = Turbine>() + + override suspend fun getOnboardingData(): Either { + return onboardingDataResponses.awaitItem() + } + + override suspend fun updateContactInfo(email: String, phoneNumber: String): Either { + return updateContactInfoResponses.awaitItem() + } +} + +internal fun testOnboardingData( + phoneNumber: String? = "070 990 12 32", + contracts: List = listOf( + OnboardingContract( + id = "contract-1", + displayName = "Home Insurance", + exposureName = "Bellmansgatan 19A", + typeOfContract = "SE_APARTMENT_RENT", + missingCoInsuredCount = 1, + isMissingPetId = false, + ), + ), + referralInformation: OnboardingReferralInformation? = OnboardingReferralInformation("CODE", 10.0, "SEK"), + hasConnectedPayinMethod: Boolean = false, + crossSells: List = listOf(OnboardingCrossSell("cs", "Pet", "For your pet", "https://x")), +): OnboardingData = OnboardingData( + email = "member@example.com", + phoneNumber = phoneNumber, + contracts = contracts, + referralInformation = referralInformation, + hasConnectedPayinMethod = hasConnectedPayinMethod, + crossSells = crossSells, +) +``` + +`OnboardingNavigatorTest.kt`: + +```kotlin +package com.hedvig.android.feature.onboarding.navigation + +import androidx.compose.runtime.mutableStateListOf +import arrow.core.right +import assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.isEmpty +import assertk.assertions.isFalse +import assertk.assertions.isTrue +import com.hedvig.android.feature.onboarding.FakeOnboardingRepository +import com.hedvig.android.feature.onboarding.data.CompleteOnboardingUseCase +import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore +import com.hedvig.android.feature.onboarding.testOnboardingData +import com.hedvig.android.navigation.common.HedvigNavKey +import com.hedvig.android.navigation.compose.Backstack +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.Test + +internal class OnboardingNavigatorTest { + private class TestBackstack : Backstack { + override val entries: MutableList = mutableStateListOf() + } + + private class FakeCompleteOnboardingUseCase : CompleteOnboardingUseCase { + var invoked: Boolean = false + + override suspend fun invoke() { + invoked = true + } + } + + private suspend fun sessionStoreWithData(repository: FakeOnboardingRepository): OnboardingSessionStore { + val store = OnboardingSessionStore(repository) + repository.onboardingDataResponses.add(testOnboardingData().right()) + store.getOrFetchSession() + return store + } + + @Test + fun `continue from welcome pushes the first step of the path`() = runTest { + val backstack = TestBackstack().apply { entries.add(OnboardingKey) } + val repository = FakeOnboardingRepository() + val navigator = OnboardingNavigator(backstack, sessionStoreWithData(repository), FakeCompleteOnboardingUseCase()) + + navigator.continueFrom(null) + + assertThat(backstack.entries).containsExactly( + OnboardingKey, + OnboardingStepKey(OnboardingStepId.AnalyticsConsent), + ) + } + + @Test + fun `continue from a mid step pushes the next step`() = runTest { + val backstack = TestBackstack().apply { + entries.add(OnboardingKey) + entries.add(OnboardingStepKey(OnboardingStepId.AnalyticsConsent)) + } + val repository = FakeOnboardingRepository() + val navigator = OnboardingNavigator(backstack, sessionStoreWithData(repository), FakeCompleteOnboardingUseCase()) + + navigator.continueFrom(OnboardingStepId.AnalyticsConsent) + + assertThat(backstack.entries.last()).isEqualTo(OnboardingStepKey(OnboardingStepId.PhoneNumber)) + } + + @Test + fun `continue from the last step marks seen and removes all onboarding keys`() = runTest { + val backstack = TestBackstack().apply { + entries.add(OnboardingKey) + entries.add(OnboardingStepKey(OnboardingStepId.BundleDiscount)) + } + val repository = FakeOnboardingRepository() + val completeOnboarding = FakeCompleteOnboardingUseCase() + val navigator = OnboardingNavigator(backstack, sessionStoreWithData(repository), completeOnboarding) + + navigator.continueFrom(OnboardingStepId.BundleDiscount) + + assertThat(completeOnboarding.invoked).isTrue() + assertThat(backstack.entries).isEmpty() + } + + @Test + fun `exit marks seen and removes all onboarding keys, leaving surrounding entries alone`() = runTest { + val backstack = TestBackstack().apply { + entries.add(NonOnboardingKey) + entries.add(OnboardingKey) + entries.add(OnboardingStepKey(OnboardingStepId.AnalyticsConsent)) + } + val repository = FakeOnboardingRepository() + val completeOnboarding = FakeCompleteOnboardingUseCase() + val navigator = OnboardingNavigator(backstack, sessionStoreWithData(repository), completeOnboarding) + + navigator.exitOnboarding() + + assertThat(completeOnboarding.invoked).isTrue() + assertThat(backstack.entries).containsExactly(NonOnboardingKey) + } + + @Test + fun `continue without a session exits the flow without marking crash-level state`() = runTest { + val backstack = TestBackstack().apply { entries.add(OnboardingKey) } + val completeOnboarding = FakeCompleteOnboardingUseCase() + val navigator = OnboardingNavigator(backstack, OnboardingSessionStore(FakeOnboardingRepository()), completeOnboarding) + + navigator.continueFrom(null) + + assertThat(backstack.entries).isEmpty() + assertThat(completeOnboarding.invoked).isTrue() + } +} + +@kotlinx.serialization.Serializable +private data object NonOnboardingKey : HedvigNavKey +``` + +(Imports used above include `assertk.assertions.isEqualTo`; adjust the import list to what the file actually references.) + +- [ ] **Step 4: Run to verify failure** + +Run: `./gradlew :feature-onboarding:testDebugUnitTest 2>&1 | grep -E "(BUILD|error:)" | head -5` +Expected: compile failure, `OnboardingNavigator` unresolved. + +- [ ] **Step 5: Implement the navigator** + +`OnboardingNavigator.kt`: + +```kotlin +package com.hedvig.android.feature.onboarding.navigation + +import com.hedvig.android.core.common.di.ActivityRetainedScope +import com.hedvig.android.feature.onboarding.data.CompleteOnboardingUseCase +import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore +import com.hedvig.android.logger.logcat +import com.hedvig.android.navigation.compose.Backstack +import com.hedvig.android.navigation.compose.add +import com.hedvig.android.navigation.compose.removeAllOf +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn + +/** + * Owns forward/exit movement through the onboarding flow. Steps are pushed as real back stack + * entries so predictive back and the up arrow behave natively; exiting removes every onboarding + * key, landing on whatever was underneath (Home). + */ +@SingleIn(ActivityRetainedScope::class) +@Inject +internal class OnboardingNavigator( + private val backstack: Backstack, + private val sessionStore: OnboardingSessionStore, + private val completeOnboardingUseCase: CompleteOnboardingUseCase, +) { + /** [current] is null when continuing from the welcome screen (OnboardingKey). */ + suspend fun continueFrom(current: OnboardingStepId?) { + val session = sessionStore.currentSession + if (session == null) { + logcat { "Onboarding continue without a session, exiting flow" } + exitOnboarding() + return + } + val next = if (current == null) { + session.path.firstOrNull() + } else { + val currentIndex = session.path.indexOf(current) + if (currentIndex == -1) null else session.path.getOrNull(currentIndex + 1) + } + if (next == null) { + exitOnboarding() + } else { + backstack.add(OnboardingStepKey(next)) + } + } + + suspend fun exitOnboarding() { + completeOnboardingUseCase.invoke() + backstack.removeAllOf() + backstack.removeAllOf() + } + + /** Reserved for the top app bar back arrow only, per the navigation rule. */ + fun navigateBack() { + backstack.navigateUp() + } +} +``` + +- [ ] **Step 6: Run tests, format, commit** + +```bash +./gradlew :feature-onboarding:testDebugUnitTest 2>&1 | grep -E "(BUILD SUCCESSFUL|FAILED)" +./gradlew ktlintFormat 2>&1 | grep -E "BUILD (SUCCESSFUL|FAILED)" +git add app/feature/feature-onboarding +git commit -m "Add onboarding session store, completion use case and navigator" +``` + +--- + +### Task 9: OnboardingStepScaffold (progress bar + back + close chrome) + +**Files:** +- Create: `app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt` + +**Interfaces:** +- Consumes: `OnboardingSession` (Task 8), design-system components. +- Produces (every step destination in Tasks 10-18 uses these exact signatures): + - `internal data class OnboardingProgress(val totalSteps: Int, val currentIndex: Int)` + - `internal fun OnboardingSession.progressFor(stepId: OnboardingStepId?): OnboardingProgress` (null = welcome at index 0; total is `path.size + 1`) + - `internal fun OnboardingStepScaffold(progress: OnboardingProgress?, showBackButton: Boolean, onBackClick: () -> Unit, onCloseClick: () -> Unit, modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit)` (progress bar hidden while `progress == null`, e.g. during a restore-refetch) + - `internal fun OnboardingStepHeader(title: String, description: String, modifier: Modifier = Modifier)` + - `internal fun ColumnScope.OnboardingStepButtons(primaryText: String, onPrimaryClick: () -> Unit, primaryEnabled: Boolean = true, secondaryText: String? = null, onSecondaryClick: (() -> Unit)? = null)` + +- [ ] **Step 1: Pin exact design-system API names** + +Before writing code, read these files and note the exact names to use (the code below uses the most likely names; correct them to what you find): + +- `app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/Icon.kt` (or grep `grep -rn "HedvigIcons.Close" app/feature --include=*.kt | head -3` and copy that call site's `Icon` import and usage shape) +- `app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/HedvigText.kt` for the text composable and how `style`/`color` are passed +- `app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/HedvigTheme.kt` for `colorScheme` token names (the code below assumes `fillPrimary` for active segments, `surfaceSecondary` for inactive, `textSecondary` for description text; substitute the real closest tokens) +- `Button.kt` for `HedvigButton` and `HedvigTextButton` exact parameter lists + +- [ ] **Step 2: Implement** + +`OnboardingStepScaffold.kt`: + +```kotlin +package com.hedvig.android.feature.onboarding.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import com.hedvig.android.design.system.hedvig.HedvigButton +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTextButton +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.Icon +import com.hedvig.android.design.system.hedvig.Surface +import com.hedvig.android.design.system.hedvig.icon.ArrowLeft +import com.hedvig.android.design.system.hedvig.icon.Close +import com.hedvig.android.design.system.hedvig.icon.HedvigIcons +import com.hedvig.android.feature.onboarding.data.OnboardingSession +import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId + +internal data class OnboardingProgress( + val totalSteps: Int, + val currentIndex: Int, +) + +/** + * Welcome (stepId == null) sits at progress position 0; path steps follow it. If [stepId] is not + * in the path (possible only when a process-death refetch rebuilt a different path under a + * restored step), this degrades to index 0 rather than crashing; the step stays fully usable. + */ +internal fun OnboardingSession.progressFor(stepId: OnboardingStepId?): OnboardingProgress { + return OnboardingProgress( + totalSteps = path.size + 1, + currentIndex = if (stepId == null) 0 else path.indexOf(stepId) + 1, + ) +} + +@Composable +internal fun OnboardingStepScaffold( + progress: OnboardingProgress?, + showBackButton: Boolean, + onBackClick: () -> Unit, + onCloseClick: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + Surface(modifier = modifier.fillMaxSize()) { + Column( + Modifier.windowInsetsPadding( + WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top), + ), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .padding(horizontal = 16.dp), + ) { + if (showBackButton) { + Icon( + imageVector = HedvigIcons.ArrowLeft, + // TODO: Add "Go back" / "Gå tillbaka" to Lokalise (or reuse an existing a11y string) + contentDescription = "Go back", + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .clickable(onClick = onBackClick), + ) + } else { + Spacer(Modifier.size(24.dp)) + } + if (progress != null) { + OnboardingProgressBar( + progress = progress, + modifier = Modifier + .weight(1f) + .padding(horizontal = 24.dp), + ) + } else { + Spacer(Modifier.weight(1f)) + } + Icon( + imageVector = HedvigIcons.Close, + // TODO: Add "Close" / "Stäng" to Lokalise (or reuse an existing a11y string) + contentDescription = "Close", + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .clickable(onClick = onCloseClick), + ) + } + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()), + content = content, + ) + } + } +} + +@Composable +private fun OnboardingProgressBar(progress: OnboardingProgress, modifier: Modifier = Modifier) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier) { + repeat(progress.totalSteps) { index -> + val isActivated = index <= progress.currentIndex + Box( + Modifier + .weight(1f) + .height(2.dp) + .clip(CircleShape) + .background( + if (isActivated) HedvigTheme.colorScheme.fillPrimary else HedvigTheme.colorScheme.surfaceSecondary, + ), + ) + } + } +} + +@Composable +internal fun OnboardingStepHeader(title: String, description: String, modifier: Modifier = Modifier) { + Column(modifier.padding(horizontal = 16.dp)) { + HedvigText(text = title) + HedvigText(text = description, color = HedvigTheme.colorScheme.textSecondary) + } +} + +/** Bottom-anchored button area: primary on top, optional ghost secondary below, matching Figma. */ +@Composable +internal fun ColumnScope.OnboardingStepButtons( + primaryText: String, + onPrimaryClick: () -> Unit, + primaryEnabled: Boolean = true, + secondaryText: String? = null, + onSecondaryClick: (() -> Unit)? = null, +) { + Spacer(Modifier.weight(1f)) + Spacer(Modifier.height(16.dp)) + HedvigButton( + text = primaryText, + onClick = onPrimaryClick, + enabled = primaryEnabled, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + if (secondaryText != null && onSecondaryClick != null) { + Spacer(Modifier.height(8.dp)) + HedvigTextButton( + text = secondaryText, + onClick = onSecondaryClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + Spacer(Modifier.height(16.dp)) + Spacer(Modifier.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom))) +} +``` + +Adjust to the real API names found in Step 1 (`Icon` parameter names, `HedvigTextButton(onClick=...)` parameter order, color tokens, whether `Surface` is the right root or plain `Box(Modifier.background(...))` matches other full-screen destinations). Icon vectors: verify `HedvigIcons.ArrowLeft` and `HedvigIcons.Close` exist in `design-system-hedvig/.../icon/`; if `ArrowLeft` is missing, grep for the icon used by `TopAppBarActionType.BACK` and use that. + +- [ ] **Step 3: Compile, format, commit** + +```bash +./gradlew :feature-onboarding:compileDebugKotlin 2>&1 | grep -E "(BUILD SUCCESSFUL|FAILED|error:)" | head -10 +./gradlew ktlintFormat 2>&1 | grep -E "BUILD (SUCCESSFUL|FAILED)" +git add app/feature/feature-onboarding +git commit -m "Add onboarding step scaffold with segmented progress bar" +``` + +--- + +### Task 10: Welcome step + +**Files:** +- Create: `app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt` +- Test: `app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomePresenterTest.kt` + +**Interfaces:** +- Consumes: `OnboardingSessionStore.getOrFetchSession()`, `OnboardingNavigator.continueFrom(null)` / `exitOnboarding()`, `OnboardingStepScaffold`/`progressFor`/`OnboardingStepButtons` (Task 9), `metroViewModel` pattern. +- Produces: `OnboardingWelcomeViewModel` (`@Inject @HedvigViewModel(ActivityRetainedScope::class)`), `OnboardingWelcomeDestination(viewModel)`. Task 19 registers `entry` with these. + +Figma (node 597:1192): app-icon graphic with red "1" badge, title "Welcome to Hedvig", subtitle "Follow the steps to get started with your new insurance", primary button "Get started". No back arrow. The badge/checkmark animations noted in Figma are out of scope for v1 (tracked in the final task's follow-ups list). + +- [ ] **Step 1: Write the failing presenter test** + +`OnboardingWelcomePresenterTest.kt`: + +```kotlin +package com.hedvig.android.feature.onboarding.ui.welcome + +import androidx.compose.runtime.mutableStateListOf +import arrow.core.left +import arrow.core.right +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.feature.onboarding.FakeOnboardingRepository +import com.hedvig.android.feature.onboarding.data.CompleteOnboardingUseCase +import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore +import com.hedvig.android.feature.onboarding.navigation.OnboardingKey +import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator +import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId +import com.hedvig.android.feature.onboarding.navigation.OnboardingStepKey +import com.hedvig.android.feature.onboarding.testOnboardingData +import com.hedvig.android.molecule.test.test +import com.hedvig.android.navigation.common.HedvigNavKey +import com.hedvig.android.navigation.compose.Backstack +import kotlinx.coroutines.test.runTest +import org.junit.Test + +internal class OnboardingWelcomePresenterTest { + private class TestBackstack : Backstack { + override val entries: MutableList = mutableStateListOf() + } + + private class NoopCompleteOnboardingUseCase : CompleteOnboardingUseCase { + override suspend fun invoke() {} + } + + @Test + fun `session fetch success shows content with the progress of the whole path`() = runTest { + val repository = FakeOnboardingRepository() + val sessionStore = OnboardingSessionStore(repository) + val navigator = OnboardingNavigator(TestBackstack(), sessionStore, NoopCompleteOnboardingUseCase()) + val presenter = OnboardingWelcomePresenter(sessionStore, navigator) + + presenter.test(OnboardingWelcomeUiState.Loading) { + skipItems(1) + repository.onboardingDataResponses.add(testOnboardingData().right()) + val content = awaitItem() + assertThat(content).isInstanceOf() + // default test data: consent+phone+theme+coinsured+invite+payment+bundle = 7 steps + welcome + assertThat((content as OnboardingWelcomeUiState.Content).progress.totalSteps).isEqualTo(8) + assertThat(content.progress.currentIndex).isEqualTo(0) + } + } + + @Test + fun `session fetch failure shows error, retry refetches`() = runTest { + val repository = FakeOnboardingRepository() + val sessionStore = OnboardingSessionStore(repository) + val navigator = OnboardingNavigator(TestBackstack(), sessionStore, NoopCompleteOnboardingUseCase()) + val presenter = OnboardingWelcomePresenter(sessionStore, navigator) + + presenter.test(OnboardingWelcomeUiState.Loading) { + skipItems(1) + repository.onboardingDataResponses.add(ErrorMessage("boom").left()) + assertThat(awaitItem()).isInstanceOf() + sendEvent(OnboardingWelcomeEvent.Retry) + skipItems(1) // back to Loading + repository.onboardingDataResponses.add(testOnboardingData().right()) + assertThat(awaitItem()).isInstanceOf() + } + } + + @Test + fun `get started pushes the first path step onto the backstack`() = runTest { + val backstack = TestBackstack().apply { entries.add(OnboardingKey) } + val repository = FakeOnboardingRepository() + val sessionStore = OnboardingSessionStore(repository) + val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) + val presenter = OnboardingWelcomePresenter(sessionStore, navigator) + + presenter.test(OnboardingWelcomeUiState.Loading) { + skipItems(1) + repository.onboardingDataResponses.add(testOnboardingData().right()) + awaitItem() + sendEvent(OnboardingWelcomeEvent.GetStarted) + awaitUnchanged() + assertThat(backstack.entries.last()).isEqualTo(OnboardingStepKey(OnboardingStepId.AnalyticsConsent)) + } + } +} +``` + +(If `awaitUnchanged()` is not the right way to let the launched event settle in `molecule-test`, mirror what an existing test in the repo does after `sendEvent` with a side effect, e.g. `runCurrent()` via the test scheduler.) + +- [ ] **Step 2: Run to verify failure** + +Run: `./gradlew :feature-onboarding:testDebugUnitTest 2>&1 | grep -E "(BUILD|error:)" | head -5` +Expected: compile failure. + +- [ ] **Step 3: Implement ViewModel, Presenter, Destination** + +`OnboardingWelcomeDestination.kt`: + +```kotlin +package com.hedvig.android.feature.onboarding.ui.welcome + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.hedvig.android.core.common.di.ActivityRetainedScope +import com.hedvig.android.core.common.di.HedvigViewModel +import com.hedvig.android.design.system.hedvig.HedvigErrorSection +import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgressDebounced +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore +import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator +import com.hedvig.android.feature.onboarding.ui.OnboardingProgress +import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons +import com.hedvig.android.feature.onboarding.ui.OnboardingStepScaffold +import com.hedvig.android.feature.onboarding.ui.progressFor +import com.hedvig.android.molecule.public.MoleculePresenter +import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.molecule.public.MoleculeViewModel +import dev.zacsweers.metro.Inject +import kotlinx.coroutines.launch + +@Inject +@HedvigViewModel(ActivityRetainedScope::class) +internal class OnboardingWelcomeViewModel( + sessionStore: OnboardingSessionStore, + navigator: OnboardingNavigator, +) : MoleculeViewModel( + initialState = OnboardingWelcomeUiState.Loading, + presenter = OnboardingWelcomePresenter(sessionStore, navigator), + ) + +internal class OnboardingWelcomePresenter( + private val sessionStore: OnboardingSessionStore, + private val navigator: OnboardingNavigator, +) : MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present( + lastState: OnboardingWelcomeUiState, + ): OnboardingWelcomeUiState { + var currentState by remember { mutableStateOf(lastState) } + var loadIteration by remember { mutableIntStateOf(0) } + + LaunchedEffect(loadIteration) { + if (currentState is OnboardingWelcomeUiState.Content) return@LaunchedEffect + currentState = OnboardingWelcomeUiState.Loading + sessionStore.getOrFetchSession().fold( + ifLeft = { currentState = OnboardingWelcomeUiState.Error }, + ifRight = { session -> + currentState = OnboardingWelcomeUiState.Content(session.progressFor(null)) + }, + ) + } + + CollectEvents { event -> + when (event) { + OnboardingWelcomeEvent.Retry -> loadIteration++ + OnboardingWelcomeEvent.GetStarted -> launch { navigator.continueFrom(null) } + OnboardingWelcomeEvent.Close -> launch { navigator.exitOnboarding() } + } + } + + return currentState + } +} + +internal sealed interface OnboardingWelcomeUiState { + data object Loading : OnboardingWelcomeUiState + + data object Error : OnboardingWelcomeUiState + + data class Content(val progress: OnboardingProgress) : OnboardingWelcomeUiState +} + +internal sealed interface OnboardingWelcomeEvent { + data object Retry : OnboardingWelcomeEvent + + data object GetStarted : OnboardingWelcomeEvent + + data object Close : OnboardingWelcomeEvent +} + +@Composable +internal fun OnboardingWelcomeDestination(viewModel: OnboardingWelcomeViewModel) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + OnboardingStepScaffold( + progress = (uiState as? OnboardingWelcomeUiState.Content)?.progress, + showBackButton = false, + onBackClick = {}, + onCloseClick = { viewModel.emit(OnboardingWelcomeEvent.Close) }, + ) { + when (uiState) { + OnboardingWelcomeUiState.Loading -> HedvigFullScreenCenterAlignedProgressDebounced() + OnboardingWelcomeUiState.Error -> HedvigErrorSection( + onButtonClick = { viewModel.emit(OnboardingWelcomeEvent.Retry) }, + ) + is OnboardingWelcomeUiState.Content -> { + Spacer(Modifier.weight(1f)) + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) { + // Static stand-in for the Figma app-icon-with-badge graphic; animation is a follow-up. + Box( + Modifier + .size(96.dp) + .background(HedvigTheme.colorScheme.fillPrimary, HedvigTheme.shapes.cornerLarge), + ) + Spacer(Modifier.height(24.dp)) + // TODO: Add "Welcome to Hedvig" / "Välkommen till Hedvig" to Lokalise + HedvigText("Welcome to Hedvig") + // TODO: Add "Follow the steps to get started with your new insurance" / + // "Följ stegen för att komma igång med din nya försäkring" to Lokalise + HedvigText( + text = "Follow the steps to get started with your new insurance", + color = HedvigTheme.colorScheme.textSecondary, + ) + } + // TODO: Add "Get started" / "Kom igång" to Lokalise + OnboardingStepButtons( + primaryText = "Get started", + onPrimaryClick = { viewModel.emit(OnboardingWelcomeEvent.GetStarted) }, + ) + } + } + } +} +``` + +(`HedvigTheme.shapes.cornerLarge`: substitute the real shape token found in Task 9's discovery. `HedvigFullScreenCenterAlignedProgressDebounced` is the loading composable the Settings screen uses; same import.) + +- [ ] **Step 4: Run tests, format, commit** + +```bash +./gradlew :feature-onboarding:testDebugUnitTest 2>&1 | grep -E "(BUILD SUCCESSFUL|FAILED)" +./gradlew ktlintFormat 2>&1 | grep -E "BUILD (SUCCESSFUL|FAILED)" +git add app/feature/feature-onboarding +git commit -m "Add onboarding welcome step" +``` + +Shared conventions for Tasks 11-18 (each step task repeats the welcome shape; only differences are spelled out per task): + +- Every step has `{Step}ViewModel` (`@Inject @HedvigViewModel(ActivityRetainedScope::class)`), `{Step}Presenter`, `{Step}UiState` (`Loading`/`Error`/`Content`), `{Step}Event` (always includes `Retry` and `Close`), and `{Step}Destination(viewModel, ...)` in one file, exactly like Task 10's welcome file. +- Every presenter loads the session with the same `LaunchedEffect(loadIteration)` block as `OnboardingWelcomePresenter`, computing `session.progressFor(OnboardingStepId.X)` for its own step id. +- `Close` always does `launch { navigator.exitOnboarding() }`; the step's forward action always ends in `launch { navigator.continueFrom(OnboardingStepId.X) }`. +- Every destination renders inside `OnboardingStepScaffold(progress, showBackButton = true, onBackClick = { navigator-back callback }, onCloseClick = { emit Close })`, with `Loading -> HedvigFullScreenCenterAlignedProgressDebounced()`, `Error -> HedvigErrorSection(onButtonClick = { emit Retry })`. The back callback is threaded from the entries function as `navigateUp: () -> Unit` (Task 19 wires it to `backstack::navigateUp`). +- Steps that return from an external flow (co-insured, pet ids, payment) add this refresh block to their destination and a `Refresh` event handled as `launch { sessionStore.refreshData() /* onRight updates content, onLeft keeps previous */ }`: + +```kotlin +var isFirstResume by rememberSaveable { mutableStateOf(true) } +LifecycleResumeEffect(Unit) { + if (isFirstResume) isFirstResume = false else viewModel.emit(/* Step */Event.Refresh) + onPauseOrDispose {} +} +``` + +- Presenter tests follow `OnboardingWelcomePresenterTest`: real `OnboardingSessionStore` + `FakeOnboardingRepository` + real `OnboardingNavigator` over a local `TestBackstack`, `presenter.test(initialState) { ... }`. + +--- + +### Task 11: Analytics consent step + +**Files:** +- Create: `app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingConsentDestination.kt` +- Test: `app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingConsentPresenterTest.kt` + +**Interfaces:** +- Consumes: shared conventions, `SettingsDataStore.setAnalyticsConsent(...)` (Task 1). +- Produces: `OnboardingConsentViewModel(sessionStore, navigator, settingsDataStore)`, `OnboardingConsentDestination(viewModel, navigateUp: () -> Unit, openPrivacyPolicy: () -> Unit)`. + +Figma (597:1211 area): title "Help us make the app better", body "We use technical tools to see how you use the app, so we can make it better.\n\nProduct analytics is completely optional and can be turned off any time in settings. This data is never used for marketing.", "Privacy policy" link, buttons "Allow" (primary) / "Deny" (ghost). Both choices advance. + +- [ ] **Step 1: Write the failing presenter test** + +Key cases (full file, following the welcome test's construction pattern; `FakeSettingsDataStore` reuses Task 2's fake, move it to `src/test/kotlin/com/hedvig/android/feature/onboarding/FakeSettingsDataStore.kt` in THIS module as its own copy since test fixtures don't cross modules): + +```kotlin +package com.hedvig.android.feature.onboarding.ui.consent + +import androidx.compose.runtime.mutableStateListOf +import arrow.core.right +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import com.hedvig.android.data.settings.datastore.AnalyticsConsent +import com.hedvig.android.feature.onboarding.FakeOnboardingRepository +import com.hedvig.android.feature.onboarding.FakeSettingsDataStore +import com.hedvig.android.feature.onboarding.data.CompleteOnboardingUseCase +import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore +import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator +import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId +import com.hedvig.android.feature.onboarding.navigation.OnboardingStepKey +import com.hedvig.android.feature.onboarding.testOnboardingData +import com.hedvig.android.molecule.test.test +import com.hedvig.android.navigation.common.HedvigNavKey +import com.hedvig.android.navigation.compose.Backstack +import kotlinx.coroutines.test.runTest +import org.junit.Test + +internal class OnboardingConsentPresenterTest { + private class TestBackstack : Backstack { + override val entries: MutableList = mutableStateListOf() + } + + private class NoopCompleteOnboardingUseCase : CompleteOnboardingUseCase { + override suspend fun invoke() {} + } + + @Test + fun `allow stores GRANTED and advances to the next step`() = runTest { + val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.AnalyticsConsent)) } + val repository = FakeOnboardingRepository() + val sessionStore = OnboardingSessionStore(repository) + val settingsDataStore = FakeSettingsDataStore() + val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) + val presenter = OnboardingConsentPresenter(sessionStore, navigator, settingsDataStore) + + presenter.test(OnboardingConsentUiState.Loading) { + skipItems(1) + repository.onboardingDataResponses.add(testOnboardingData().right()) + assertThat(awaitItem()).isInstanceOf() + sendEvent(OnboardingConsentEvent.Allow) + awaitUnchanged() + assertThat(settingsDataStore.consent.value).isEqualTo(AnalyticsConsent.GRANTED) + assertThat(backstack.entries.last()).isEqualTo(OnboardingStepKey(OnboardingStepId.PhoneNumber)) + } + } + + @Test + fun `deny stores DENIED and still advances`() = runTest { + val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.AnalyticsConsent)) } + val repository = FakeOnboardingRepository() + val sessionStore = OnboardingSessionStore(repository) + val settingsDataStore = FakeSettingsDataStore() + val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) + val presenter = OnboardingConsentPresenter(sessionStore, navigator, settingsDataStore) + + presenter.test(OnboardingConsentUiState.Loading) { + skipItems(1) + repository.onboardingDataResponses.add(testOnboardingData().right()) + awaitItem() + sendEvent(OnboardingConsentEvent.Deny) + awaitUnchanged() + assertThat(settingsDataStore.consent.value).isEqualTo(AnalyticsConsent.DENIED) + assertThat(backstack.entries.last()).isEqualTo(OnboardingStepKey(OnboardingStepId.PhoneNumber)) + } + } +} +``` + +- [ ] **Step 2: Run to verify failure** (same command as Task 10 Step 2) + +- [ ] **Step 3: Implement** + +`OnboardingConsentDestination.kt` (ViewModel + Presenter + state/events + Destination in one file, welcome-file shape). The differing parts: + +```kotlin +@Inject +@HedvigViewModel(ActivityRetainedScope::class) +internal class OnboardingConsentViewModel( + sessionStore: OnboardingSessionStore, + navigator: OnboardingNavigator, + settingsDataStore: SettingsDataStore, +) : MoleculeViewModel( + initialState = OnboardingConsentUiState.Loading, + presenter = OnboardingConsentPresenter(sessionStore, navigator, settingsDataStore), + ) + +internal class OnboardingConsentPresenter( + private val sessionStore: OnboardingSessionStore, + private val navigator: OnboardingNavigator, + private val settingsDataStore: SettingsDataStore, +) : MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present( + lastState: OnboardingConsentUiState, + ): OnboardingConsentUiState { + var currentState by remember { mutableStateOf(lastState) } + var loadIteration by remember { mutableIntStateOf(0) } + + LaunchedEffect(loadIteration) { + if (currentState is OnboardingConsentUiState.Content) return@LaunchedEffect + currentState = OnboardingConsentUiState.Loading + sessionStore.getOrFetchSession().fold( + ifLeft = { currentState = OnboardingConsentUiState.Error }, + ifRight = { session -> + currentState = OnboardingConsentUiState.Content(session.progressFor(OnboardingStepId.AnalyticsConsent)) + }, + ) + } + + CollectEvents { event -> + when (event) { + OnboardingConsentEvent.Retry -> loadIteration++ + OnboardingConsentEvent.Close -> launch { navigator.exitOnboarding() } + OnboardingConsentEvent.Allow -> launch { + settingsDataStore.setAnalyticsConsent(AnalyticsConsent.GRANTED) + navigator.continueFrom(OnboardingStepId.AnalyticsConsent) + } + OnboardingConsentEvent.Deny -> launch { + settingsDataStore.setAnalyticsConsent(AnalyticsConsent.DENIED) + navigator.continueFrom(OnboardingStepId.AnalyticsConsent) + } + } + } + + return currentState + } +} + +internal sealed interface OnboardingConsentUiState { + data object Loading : OnboardingConsentUiState + + data object Error : OnboardingConsentUiState + + data class Content(val progress: OnboardingProgress) : OnboardingConsentUiState +} + +internal sealed interface OnboardingConsentEvent { + data object Retry : OnboardingConsentEvent + + data object Close : OnboardingConsentEvent + + data object Allow : OnboardingConsentEvent + + data object Deny : OnboardingConsentEvent +} +``` + +Destination content (inside the shared scaffold; header at the top, buttons at the bottom): + +```kotlin +@Composable +internal fun OnboardingConsentDestination( + viewModel: OnboardingConsentViewModel, + navigateUp: () -> Unit, + openPrivacyPolicy: () -> Unit, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + OnboardingStepScaffold( + progress = (uiState as? OnboardingConsentUiState.Content)?.progress, + showBackButton = true, + onBackClick = navigateUp, + onCloseClick = { viewModel.emit(OnboardingConsentEvent.Close) }, + ) { + when (uiState) { + OnboardingConsentUiState.Loading -> HedvigFullScreenCenterAlignedProgressDebounced() + OnboardingConsentUiState.Error -> HedvigErrorSection( + onButtonClick = { viewModel.emit(OnboardingConsentEvent.Retry) }, + ) + is OnboardingConsentUiState.Content -> { + Spacer(Modifier.height(8.dp)) + OnboardingStepHeader( + // TODO: Add "Help us make the app better" / "Hjälp oss göra appen bättre" to Lokalise + title = "Help us make the app better", + // TODO: Add the body copy below (and its Swedish translation) to Lokalise + description = "We use technical tools to see how you use the app, so we can make it better.\n\n" + + "Product analytics is completely optional and can be turned off any time in settings. " + + "This data is never used for marketing.", + ) + Spacer(Modifier.weight(1f)) + // TODO: Add "Privacy policy" / "Integritetspolicy" to Lokalise + HedvigTextButton( + text = "Privacy policy", + onClick = openPrivacyPolicy, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + OnboardingStepButtons( + // TODO: Add "Allow" / "Tillåt" to Lokalise + primaryText = "Allow", + onPrimaryClick = { viewModel.emit(OnboardingConsentEvent.Allow) }, + // TODO: Add "Deny" / "Neka" to Lokalise + secondaryText = "Deny", + onSecondaryClick = { viewModel.emit(OnboardingConsentEvent.Deny) }, + ) + } + } + } +} +``` + +`openPrivacyPolicy` is a `() -> Unit` threaded from `:app` in Task 19 (it calls `openUrl` with the privacy policy URL that `:app` already knows; grep `:app` and `feature-login` for an existing privacy-policy URL constant and reuse it there, keeping this module URL-free). + +- [ ] **Step 4: Run tests, format, commit** + +```bash +./gradlew :feature-onboarding:testDebugUnitTest 2>&1 | grep -E "(BUILD SUCCESSFUL|FAILED)" +./gradlew ktlintFormat 2>&1 | grep -E "BUILD (SUCCESSFUL|FAILED)" +git add app/feature/feature-onboarding +git commit -m "Add onboarding analytics consent step" +``` + +--- + +### Task 12: Phone number step + +**Files:** +- Create: `app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt` +- Test: `app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhonePresenterTest.kt` + +**Interfaces:** +- Consumes: shared conventions, `OnboardingRepository.updateContactInfo(email, phoneNumber)` (Task 5; email comes from the session's `OnboardingData.email`, resubmitted unchanged since the mutation requires both fields). +- Produces: `OnboardingPhoneViewModel(sessionStore, navigator, onboardingRepository)`, `OnboardingPhoneDestination(viewModel, navigateUp)`. + +Figma: title "Phone number", body "Add your phone number so we can reach you if something happens", text field labeled "Phone number" pre-filled with the current number, buttons "Save" (primary) / "Do this later" (ghost). Save failure shows an inline field error and stays. + +- [ ] **Step 1: Write the failing presenter test** + +Cases (full file following Task 11's construction pattern): + +1. `content pre-fills the member's phone number`: fetch session with `phoneNumber = "070 990 12 32"`, assert `Content.phoneNumber == "070 990 12 32"`. +2. `save success advances to the next step`: `sendEvent(UpdatePhoneNumber("0701234567"))`, `sendEvent(Save)`, respond `repository.updateContactInfoResponses.add(Unit.right())`, assert backstack last == `OnboardingStepKey(OnboardingStepId.Theme)`. +3. `save failure shows inline error and does not advance`: respond `add(ErrorMessage("nope").left())`, assert `Content.showSubmissionError` is true and backstack unchanged. +4. `do this later advances without calling the mutation`: `sendEvent(DoThisLater)`, assert backstack advanced and `repository.updateContactInfoResponses` received no request (Turbine: `expectNoEvents()` on the request side is not available on a plain `Turbine` used as a response queue, so instead track calls with a `var updateContactInfoCallCount` added to `FakeOnboardingRepository` and assert it is 0). + +Add to `FakeOnboardingRepository` (Task 8 fixture): `var updateContactInfoCallCount: Int = 0` incremented at the top of `updateContactInfo`. + +- [ ] **Step 2: Run to verify failure** (same command) + +- [ ] **Step 3: Implement** + +State/events and presenter (welcome-file shape; destination below): + +```kotlin +internal sealed interface OnboardingPhoneUiState { + data object Loading : OnboardingPhoneUiState + + data object Error : OnboardingPhoneUiState + + data class Content( + val progress: OnboardingProgress, + val phoneNumber: String, + val isSubmitting: Boolean = false, + val showSubmissionError: Boolean = false, + ) : OnboardingPhoneUiState +} + +internal sealed interface OnboardingPhoneEvent { + data object Retry : OnboardingPhoneEvent + + data object Close : OnboardingPhoneEvent + + data class UpdatePhoneNumber(val phoneNumber: String) : OnboardingPhoneEvent + + data object Save : OnboardingPhoneEvent + + data object DoThisLater : OnboardingPhoneEvent +} + +internal class OnboardingPhonePresenter( + private val sessionStore: OnboardingSessionStore, + private val navigator: OnboardingNavigator, + private val onboardingRepository: OnboardingRepository, +) : MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present( + lastState: OnboardingPhoneUiState, + ): OnboardingPhoneUiState { + var currentState by remember { mutableStateOf(lastState) } + var loadIteration by remember { mutableIntStateOf(0) } + var submitIteration by remember { mutableIntStateOf(0) } + + LaunchedEffect(loadIteration) { + if (currentState is OnboardingPhoneUiState.Content) return@LaunchedEffect + currentState = OnboardingPhoneUiState.Loading + sessionStore.getOrFetchSession().fold( + ifLeft = { currentState = OnboardingPhoneUiState.Error }, + ifRight = { session -> + currentState = OnboardingPhoneUiState.Content( + progress = session.progressFor(OnboardingStepId.PhoneNumber), + phoneNumber = session.data.phoneNumber.orEmpty(), + ) + }, + ) + } + + LaunchedEffect(submitIteration) { + if (submitIteration == 0) return@LaunchedEffect + val content = currentState as? OnboardingPhoneUiState.Content ?: return@LaunchedEffect + val session = sessionStore.currentSession ?: return@LaunchedEffect + currentState = content.copy(isSubmitting = true, showSubmissionError = false) + onboardingRepository.updateContactInfo( + email = session.data.email, + phoneNumber = content.phoneNumber, + ).fold( + ifLeft = { + currentState = content.copy(isSubmitting = false, showSubmissionError = true) + }, + ifRight = { + navigator.continueFrom(OnboardingStepId.PhoneNumber) + }, + ) + } + + CollectEvents { event -> + when (event) { + OnboardingPhoneEvent.Retry -> loadIteration++ + OnboardingPhoneEvent.Close -> launch { navigator.exitOnboarding() } + is OnboardingPhoneEvent.UpdatePhoneNumber -> { + val content = currentState as? OnboardingPhoneUiState.Content ?: return@CollectEvents + currentState = content.copy(phoneNumber = event.phoneNumber, showSubmissionError = false) + } + OnboardingPhoneEvent.Save -> submitIteration++ + OnboardingPhoneEvent.DoThisLater -> launch { navigator.continueFrom(OnboardingStepId.PhoneNumber) } + } + } + + return currentState + } +} +``` + +Destination content for the `Content` branch: + +```kotlin + Spacer(Modifier.height(8.dp)) + OnboardingStepHeader( + // TODO: Add "Phone number" / "Telefonnummer" to Lokalise + title = "Phone number", + // TODO: Add "Add your phone number so we can reach you if something happens" / + // "Lägg till ditt telefonnummer så att vi kan nå dig om något händer" to Lokalise + description = "Add your phone number so we can reach you if something happens", + ) + Spacer(Modifier.height(16.dp)) + HedvigTextField( + text = content.phoneNumber, + onValueChange = { viewModel.emit(OnboardingPhoneEvent.UpdatePhoneNumber(it)) }, + // TODO: Add "Phone number" / "Telefonnummer" to Lokalise + labelText = "Phone number", + textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, + errorState = if (content.showSubmissionError) { + // TODO: Add "Could not save, please try again" / "Kunde inte spara, försök igen" to Lokalise + HedvigTextFieldDefaults.ErrorState.Error.WithMessage("Could not save, please try again") + } else { + HedvigTextFieldDefaults.ErrorState.NoError + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + OnboardingStepButtons( + // TODO: Add "Save" / "Spara" to Lokalise + primaryText = "Save", + onPrimaryClick = { viewModel.emit(OnboardingPhoneEvent.Save) }, + primaryEnabled = !content.isSubmitting && content.phoneNumber.isNotBlank(), + // TODO: Add "Do this later" / "Gör det senare" to Lokalise + secondaryText = "Do this later", + onSecondaryClick = { viewModel.emit(OnboardingPhoneEvent.DoThisLater) }, + ) +``` + +(Verify `HedvigTextField`'s exact `errorState`/`keyboardOptions` parameter names against `HedvigTextField.kt`; the signature confirmed in research is `HedvigTextField(text, onValueChange, labelText, textFieldSize, modifier, ..., errorState, ...)`.) + +- [ ] **Step 4: Run tests, format, commit** + +```bash +./gradlew :feature-onboarding:testDebugUnitTest 2>&1 | grep -E "(BUILD SUCCESSFUL|FAILED)" +./gradlew ktlintFormat 2>&1 | grep -E "BUILD (SUCCESSFUL|FAILED)" +git add app/feature/feature-onboarding +git commit -m "Add onboarding phone number step" +``` + +--- + +### Task 13: Theme step + +**Files:** +- Create: `app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt` +- Test: `app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemePresenterTest.kt` + +**Interfaces:** +- Consumes: shared conventions, `SettingsDataStore.setTheme(theme)` / `observeTheme(): Flow`, `Theme` enum (`com.hedvig.android.theme`: `LIGHT, DARK, SYSTEM_DEFAULT`). +- Produces: `OnboardingThemeViewModel(sessionStore, navigator, settingsDataStore)`, `OnboardingThemeDestination(viewModel, navigateUp)`. + +Figma: title "Choose theme", body "Customize the look of the app", three options (System "Uses your phone's setting", Light "Set light mode", Dark "Set dark mode"), hint "You can change these settings later", single primary "Continue". Selection applies immediately (the app-wide theme observes `SettingsDataStore` already, via `HedvigAppState.darkTheme`). + +- [ ] **Step 1: Write the failing presenter test** + +Full file following Task 11's construction pattern (TestBackstack + FakeOnboardingRepository + FakeSettingsDataStore + real session store/navigator). `FakeSettingsDataStore` needs a working theme: back `setTheme`/`observeTheme` with a `MutableStateFlow(null)` instead of `error("unused")`. Cases: + +1. `selected theme defaults to SYSTEM_DEFAULT when nothing stored`: fetch session, assert `Content.selectedTheme == Theme.SYSTEM_DEFAULT`. +2. `selecting a theme persists it and updates the selection`: `sendEvent(SelectTheme(Theme.DARK))`, then assert the fake's theme flow value is `Theme.DARK` and the next emitted `Content.selectedTheme == Theme.DARK`. +3. `continue advances to the next path step`: `sendEvent(Continue)`, `awaitUnchanged()`, assert `backstack.entries.last()` equals the step after Theme for the default test data (`OnboardingStepKey(OnboardingStepId.CoInsured)`). + +- [ ] **Step 2: Run to verify failure** (same command) + +- [ ] **Step 3: Implement** + +State/events and the presenter's differing parts (rest follows the welcome-file shape): + +```kotlin +internal sealed interface OnboardingThemeUiState { + data object Loading : OnboardingThemeUiState + + data object Error : OnboardingThemeUiState + + data class Content( + val progress: OnboardingProgress, + val selectedTheme: Theme, + ) : OnboardingThemeUiState +} + +internal sealed interface OnboardingThemeEvent { + data object Retry : OnboardingThemeEvent + + data object Close : OnboardingThemeEvent + + data class SelectTheme(val theme: Theme) : OnboardingThemeEvent + + data object Continue : OnboardingThemeEvent +} +``` + +Presenter specifics: alongside the shared session `LaunchedEffect`, observe the stored theme and merge it into content: + +```kotlin + val storedTheme = settingsDataStore.observeTheme().collectAsState(null).value + // In the return: if currentState is Content, return it with + // selectedTheme = storedTheme ?: Theme.SYSTEM_DEFAULT +``` + +`CollectEvents` additions: + +```kotlin + is OnboardingThemeEvent.SelectTheme -> launch { settingsDataStore.setTheme(event.theme) } + OnboardingThemeEvent.Continue -> launch { navigator.continueFrom(OnboardingStepId.Theme) } +``` + +Destination `Content` branch: header, then three option rows, hint, and the button: + +```kotlin +@Composable +private fun ThemeOptionRow( + title: String, + subtitle: String, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxWidth() + .clip(HedvigTheme.shapes.cornerMedium) + .clickable(onClick = onClick) + .padding(16.dp), + ) { + Column(Modifier.weight(1f)) { + HedvigText(title) + HedvigText(subtitle, color = HedvigTheme.colorScheme.textSecondary) + } + if (isSelected) { + Icon(imageVector = HedvigIcons.Checkmark, contentDescription = null) + } + } +} +``` + +Rows (all copy gets `// TODO: Add ... to Lokalise` comments; the theme display names may already exist as string resources used by the profile settings theme dialog, grep `SETTINGS_THEME` in `feature-profile` first and reuse `Res.string` ids if present): + +- "System" / "Uses your phone's setting" -> `SelectTheme(Theme.SYSTEM_DEFAULT)` +- "Light" / "Set light mode" -> `SelectTheme(Theme.LIGHT)` +- "Dark" / "Set dark mode" -> `SelectTheme(Theme.DARK)` + +Then the hint ("You can change these settings later", `textSecondary`, centered) directly above `OnboardingStepButtons(primaryText = "Continue", onPrimaryClick = { viewModel.emit(OnboardingThemeEvent.Continue) })`. + +- [ ] **Step 4: Run tests, format, commit** + +```bash +./gradlew :feature-onboarding:testDebugUnitTest 2>&1 | grep -E "(BUILD SUCCESSFUL|FAILED)" +./gradlew ktlintFormat 2>&1 | grep -E "BUILD (SUCCESSFUL|FAILED)" +git add app/feature/feature-onboarding +git commit -m "Add onboarding theme step" +``` + +--- + +### Task 14: Add co-insured step + +**Files:** +- Create: `app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt` +- Modify: `app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingNavigator.kt` +- Test: `app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredPresenterTest.kt` + +**Interfaces:** +- Consumes: shared conventions (including the refresh-on-resume block), `CoInsuredAddInfoKey(contractId, type)` + `CoInsuredFlowType.CoInsured` from `feature-edit-coinsured-navigation` (a legal dependency). +- Produces: navigator gains `fun openAddCoInsured(contractId: String)`; `OnboardingCoInsuredViewModel(sessionStore, navigator)`, `OnboardingCoInsuredDestination(viewModel, navigateUp)`. + +Figma: title "Add co-insured", body "So we know who's covered by your insurance", one row per contract that had missing co-insured info when the step loaded (contract display name + exposure, trailing "Add" button; a checkmark replaces the button once complete), hint "You can add this information later", buttons "Continue" / "Do this later". Rows are pinned at step load so a completed row shows its done state instead of vanishing (Figma's second variant). + +- [ ] **Step 1: Add the navigator method** + +In `OnboardingNavigator`: + +```kotlin + /** Pushes the existing edit-co-insured flow; it pops itself back here when done. */ + fun openAddCoInsured(contractId: String) { + backstack.add(CoInsuredAddInfoKey(contractId, CoInsuredFlowType.CoInsured)) + } +``` + +(Imports: `com.hedvig.android.feature.editcoinsured.navigation.CoInsuredAddInfoKey`, `...CoInsuredFlowType`; verify exact package in `feature-edit-coinsured-navigation`.) + +- [ ] **Step 2: Write the failing presenter test** + +Cases (Task 11 construction pattern): + +1. `rows are pinned from the contracts missing co-insured at load`: default test data has one contract with `missingCoInsuredCount = 1`; assert `Content.rows` has one row with `isComplete == false`. +2. `add navigates to the edit co-insured flow`: `sendEvent(AddCoInsured("contract-1"))`, `awaitUnchanged()`, assert `backstack.entries.last()` is `CoInsuredAddInfoKey("contract-1", CoInsuredFlowType.CoInsured)`. +3. `refresh marks completed rows instead of removing them`: `sendEvent(Refresh)`, respond with `testOnboardingData(contracts = listOf()).right()`, assert the single row now has `isComplete == true` (row count unchanged). +4. `continue advances`: assert next key is `OnboardingStepKey(OnboardingStepId.InviteFriend)` for default data (PetIds is skipped: default contract has `isMissingPetId = false`). + +- [ ] **Step 3: Run to verify failure** (same command) + +- [ ] **Step 4: Implement** + +State/events: + +```kotlin +internal data class CoInsuredRow( + val contractId: String, + val displayName: String, + val exposureName: String, + val isComplete: Boolean, +) + +internal sealed interface OnboardingCoInsuredUiState { + data object Loading : OnboardingCoInsuredUiState + + data object Error : OnboardingCoInsuredUiState + + data class Content( + val progress: OnboardingProgress, + val rows: List, + ) : OnboardingCoInsuredUiState +} + +internal sealed interface OnboardingCoInsuredEvent { + data object Retry : OnboardingCoInsuredEvent + + data object Close : OnboardingCoInsuredEvent + + data object Refresh : OnboardingCoInsuredEvent + + data class AddCoInsured(val contractId: String) : OnboardingCoInsuredEvent + + data object Continue : OnboardingCoInsuredEvent +} +``` + +Presenter specifics beyond the shared shape: + +```kotlin + // Pin which contracts belong to this step the first time data is available, so completed + // rows render as done instead of disappearing. + var pinnedContractIds by remember { mutableStateOf?>(null) } + + fun contentFrom(session: OnboardingSession): OnboardingCoInsuredUiState.Content { + val ids = pinnedContractIds + ?: session.data.contractsWithMissingCoInsured.map { it.id }.also { pinnedContractIds = it } + return OnboardingCoInsuredUiState.Content( + progress = session.progressFor(OnboardingStepId.CoInsured), + rows = ids.mapNotNull { id -> + val contract = session.data.contracts.firstOrNull { it.id == id } ?: return@mapNotNull null + CoInsuredRow( + contractId = contract.id, + displayName = contract.displayName, + exposureName = contract.exposureName, + isComplete = contract.missingCoInsuredCount == 0, + ) + }, + ) + } +``` + +The session `LaunchedEffect` uses `contentFrom(session)`; `CollectEvents` adds: + +```kotlin + OnboardingCoInsuredEvent.Refresh -> launch { + sessionStore.refreshData().onRight { refreshed -> currentState = contentFrom(refreshed) } + // onLeft: keep the previously shown state, per the spec's error handling. + } + is OnboardingCoInsuredEvent.AddCoInsured -> navigator.openAddCoInsured(event.contractId) + OnboardingCoInsuredEvent.Continue -> launch { navigator.continueFrom(OnboardingStepId.CoInsured) } +``` + +Destination `Content` branch: header ("Add co-insured" / "So we know who's covered by your insurance", both with Lokalise TODOs), then per row: + +```kotlin + for (row in content.rows) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Column(Modifier.weight(1f)) { + HedvigText(row.displayName) + HedvigText(row.exposureName, color = HedvigTheme.colorScheme.textSecondary) + } + if (row.isComplete) { + Icon(imageVector = HedvigIcons.Checkmark, contentDescription = null) + } else { + HedvigButton( + // TODO: Add "Add" / "Lägg till" to Lokalise + text = "Add", + onClick = { viewModel.emit(OnboardingCoInsuredEvent.AddCoInsured(row.contractId)) }, + enabled = true, + buttonSize = ButtonDefaults.ButtonSize.Small, + ) + } + } + } +``` + +Hint "You can add this information later" (Lokalise TODO) above `OnboardingStepButtons(primaryText = "Continue", ..., secondaryText = "Do this later", onSecondaryClick = { viewModel.emit(OnboardingCoInsuredEvent.Continue) })`. Note: both Continue and Do this later advance the same way here; emit `Continue` from both (the Figma distinction is visual only). Include the refresh-on-resume block from the shared conventions. + +- [ ] **Step 5: Run tests, format, commit** + +```bash +./gradlew :feature-onboarding:testDebugUnitTest 2>&1 | grep -E "(BUILD SUCCESSFUL|FAILED)" +./gradlew ktlintFormat 2>&1 | grep -E "BUILD (SUCCESSFUL|FAILED)" +git add app/feature/feature-onboarding +git commit -m "Add onboarding co-insured step" +``` + +--- + +### Task 15: Pet ID numbers step + +**Files:** +- Create: `app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdDestination.kt` +- Test: `app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdPresenterTest.kt` + +**Interfaces:** +- Consumes: shared conventions. NOT `ChipIdKey`: `feature-chip-id` is not a `-navigation` module, so the destination takes `onAddPetId: (contractId: String) -> Unit`, wired by `:app` in Task 19 to `backstack.add(ChipIdKey(contractId))`. +- Produces: `OnboardingPetIdViewModel(sessionStore, navigator)`, `OnboardingPetIdDestination(viewModel, navigateUp, onAddPetId)`. + +Figma: title "Add your pets ID numbers", body "This makes it easier to help you if something happens", one pinned row per contract with `isMissingPetId` at load (pet contract display name + exposure name, "Add" button, checkmark when complete after refresh), hint "You can add this information later", Continue / Do this later. + +This task mirrors the co-insured step, which by now exists in the repo. First read the committed `app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt` and its test, then write this step's files as a full copy of that structure with these substitutions: + +- Names: `PetIdRow`, `OnboardingPetIdUiState`, `OnboardingPetIdEvent` (`Retry, Close, Refresh, AddPetId(contractId), Continue`), `OnboardingPetIdPresenter`, `OnboardingPetIdViewModel`, `OnboardingPetIdDestination`. +- Pinning source: `session.data.contractsWithMissingPetId`; completion check: `contract.isMissingPetId == false`. +- `AddPetId` is NOT handled by the navigator; the presenter cannot open it. Route it through the destination instead: the `Content` row's Add button calls `onAddPetId(row.contractId)` directly, so the event enum drops `AddPetId` entirely and the presenter handles only `Retry, Close, Refresh, Continue`. +- Progress id: `OnboardingStepId.PetIds`; continue target asserts (test) with test data where the contract has `isMissingPetId = true` and `missingCoInsuredCount = 0`: next is `OnboardingStepKey(OnboardingStepId.InviteFriend)`. +- Copy with Lokalise TODOs: "Add your pets ID numbers" / "This makes it easier to help you if something happens" / "Add" / "You can add this information later" / "Continue" / "Do this later". + +- [ ] **Step 1: Write the failing presenter test** (cases: pinned rows at load; refresh marks complete; continue advances) +- [ ] **Step 2: Run to verify failure** (same command) +- [ ] **Step 3: Implement destination file** (full welcome-file shape with the substitutions above) +- [ ] **Step 4: Run tests, format, commit** + +```bash +./gradlew :feature-onboarding:testDebugUnitTest 2>&1 | grep -E "(BUILD SUCCESSFUL|FAILED)" +./gradlew ktlintFormat 2>&1 | grep -E "BUILD (SUCCESSFUL|FAILED)" +git add app/feature/feature-onboarding +git commit -m "Add onboarding pet id step" +``` + +--- + +### Task 16: Invite a friend step + +**Files:** +- Create: `app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt` +- Test: `app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInvitePresenterTest.kt` + +**Interfaces:** +- Consumes: shared conventions, `OnboardingReferralInformation` from the session. +- Produces: `OnboardingInviteViewModel(sessionStore, navigator)`, `OnboardingInviteDestination(viewModel, navigateUp)`. + +Figma: title "Invite a friend", body "With Hedvig Forever, you get {incentive} off for every friend you invite", an illustration card of referral discounts (rendered as a simple static card listing "-{incentive}" rows is out of scope; render just the header and buttons plus one HedvigText showing the member's code), buttons "Invite a friend" (ghost, opens the system share sheet) / "Continue" (primary). + +- [ ] **Step 1: Write the failing presenter test** + +Cases: + +1. `content carries the referral code and incentive`: default test data has `OnboardingReferralInformation("CODE", 10.0, "SEK")`; assert `Content.code == "CODE"` and `Content.incentiveDisplay == "10 SEK"`. +2. `continue advances`: next for default data is `OnboardingStepKey(OnboardingStepId.ConnectPayment)`. +3. `step renders error state if the session has no referral information` (defensive: gate/path should prevent this): fetch with `referralInformation = null`, assert `Error`. + +- [ ] **Step 2: Run to verify failure** (same command) + +- [ ] **Step 3: Implement** + +State/events: + +```kotlin +internal sealed interface OnboardingInviteUiState { + data object Loading : OnboardingInviteUiState + + data object Error : OnboardingInviteUiState + + data class Content( + val progress: OnboardingProgress, + val code: String, + val incentiveDisplay: String, + ) : OnboardingInviteUiState +} + +internal sealed interface OnboardingInviteEvent { + data object Retry : OnboardingInviteEvent + + data object Close : OnboardingInviteEvent + + data object Continue : OnboardingInviteEvent +} +``` + +Presenter: session load maps `referralInformation` (null -> `Error`); `incentiveDisplay = "${referral.monthlyDiscountPerReferralAmount.toInt()} ${referral.currencyCode}"`. `Continue -> launch { navigator.continueFrom(OnboardingStepId.InviteFriend) }`. + +Destination: the share action lives in the destination (needs `Context`), mirroring `ForeverDestination`'s share-sheet approach with a local helper: + +```kotlin +private fun Context.showShareSheet(title: String, text: String) { + startActivity( + Intent.createChooser( + Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, text) + type = "text/plain" + }, + title, + ), + ) +} +``` + +`Content` branch: header ("Invite a friend" / "With Hedvig Forever, you get {incentiveDisplay} off for every friend you invite", Lokalise TODOs), a centered `HedvigText(content.code)` as the code display, then: + +```kotlin + OnboardingStepButtons( + // TODO: Add "Continue" / "Fortsätt" to Lokalise + primaryText = "Continue", + onPrimaryClick = { viewModel.emit(OnboardingInviteEvent.Continue) }, + // TODO: Add "Invite a friend" / "Bjud in en vän" to Lokalise + secondaryText = "Invite a friend", + onSecondaryClick = { + // TODO: Add the share message to Lokalise; align copy with the Forever screen's + // REFERRAL_SMS_MESSAGE once product confirms. + context.showShareSheet( + title = "Invite a friend", + text = "With my code you get a discount at Hedvig. Use code ${content.code}.", + ) + }, + ) +``` + +(`val context = LocalContext.current` at the destination top.) + +- [ ] **Step 4: Run tests, format, commit** + +```bash +./gradlew :feature-onboarding:testDebugUnitTest 2>&1 | grep -E "(BUILD SUCCESSFUL|FAILED)" +./gradlew ktlintFormat 2>&1 | grep -E "BUILD (SUCCESSFUL|FAILED)" +git add app/feature/feature-onboarding +git commit -m "Add onboarding invite a friend step" +``` + +--- + +### Task 17: Connect payment step + +**Files:** +- Create: `app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt` +- Modify: `app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingNavigator.kt` +- Test: `app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentPresenterTest.kt` + +**Interfaces:** +- Consumes: shared conventions (with refresh-on-resume), `TrustlyKey` from `feature-connect-payment-trustly-navigation` (a legal dependency). +- Produces: navigator gains `fun openConnectPayment() { backstack.add(TrustlyKey) }`; `OnboardingPaymentViewModel(sessionStore, navigator)`, `OnboardingPaymentDestination(viewModel, navigateUp)`. + +Figma, two variants: not connected shows title "Connect payment", body "Add a payment method to activate your insurance", caption "Adding a payment method is required to keep your insurance active", single primary "Connect payment" (no "Do this later"; the X remains the only skip). Connected (after returning from Trustly and refreshing) shows a checkmark graphic, hint "You can switch accounts later in settings", primary "Continue". + +- [ ] **Step 1: Add the navigator method** + +```kotlin + /** Pushes the Trustly connect-payment flow; it pops itself back here when done. */ + fun openConnectPayment() { + backstack.add(TrustlyKey) + } +``` + +(Import `com.hedvig.android.feature.connect.payment.trustly.ui.TrustlyKey`.) + +- [ ] **Step 2: Write the failing presenter test** + +Cases: + +1. `not connected content when payin methods are missing`: default test data (`hasConnectedPayinMethod = false`), assert `Content.isConnected == false`. +2. `connect payment pushes the trustly flow`: `sendEvent(ConnectPayment)`, `awaitUnchanged()`, assert `backstack.entries.last()` is `TrustlyKey`. +3. `refresh after returning flips to connected`: `sendEvent(Refresh)`, respond `testOnboardingData(hasConnectedPayinMethod = true).right()`, assert `Content.isConnected == true`. +4. `continue advances`: for default data, next is `OnboardingStepKey(OnboardingStepId.BundleDiscount)`. + +- [ ] **Step 3: Run to verify failure** (same command) + +- [ ] **Step 4: Implement** + +State/events: + +```kotlin +internal sealed interface OnboardingPaymentUiState { + data object Loading : OnboardingPaymentUiState + + data object Error : OnboardingPaymentUiState + + data class Content( + val progress: OnboardingProgress, + val isConnected: Boolean, + ) : OnboardingPaymentUiState +} + +internal sealed interface OnboardingPaymentEvent { + data object Retry : OnboardingPaymentEvent + + data object Close : OnboardingPaymentEvent + + data object Refresh : OnboardingPaymentEvent + + data object ConnectPayment : OnboardingPaymentEvent + + data object Continue : OnboardingPaymentEvent +} +``` + +Presenter specifics: content maps `isConnected = session.data.hasConnectedPayinMethod`; `CollectEvents` adds: + +```kotlin + OnboardingPaymentEvent.Refresh -> launch { + sessionStore.refreshData().onRight { refreshed -> + currentState = OnboardingPaymentUiState.Content( + progress = refreshed.progressFor(OnboardingStepId.ConnectPayment), + isConnected = refreshed.data.hasConnectedPayinMethod, + ) + } + } + OnboardingPaymentEvent.ConnectPayment -> navigator.openConnectPayment() + OnboardingPaymentEvent.Continue -> launch { navigator.continueFrom(OnboardingStepId.ConnectPayment) } +``` + +Destination `Content` branch (include the refresh-on-resume block): + +```kotlin + is OnboardingPaymentUiState.Content -> { + Spacer(Modifier.height(8.dp)) + if (!content.isConnected) { + OnboardingStepHeader( + // TODO: Add "Connect payment" / "Koppla betalning" to Lokalise + title = "Connect payment", + // TODO: Add "Add a payment method to activate your insurance" / + // "Lägg till en betalmetod för att aktivera din försäkring" to Lokalise + description = "Add a payment method to activate your insurance", + ) + Spacer(Modifier.weight(1f)) + // TODO: Add "Adding a payment method is required to keep your insurance active" / + // "Du behöver lägga till en betalmetod för att hålla din försäkring aktiv" to Lokalise + HedvigText( + text = "Adding a payment method is required to keep your insurance active", + color = HedvigTheme.colorScheme.textSecondary, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(horizontal = 32.dp), + ) + OnboardingStepButtons( + // TODO: Add "Connect payment" / "Koppla betalning" to Lokalise + primaryText = "Connect payment", + onPrimaryClick = { viewModel.emit(OnboardingPaymentEvent.ConnectPayment) }, + ) + } else { + OnboardingStepHeader( + // TODO: Add "Connect payment" / "Koppla betalning" to Lokalise + title = "Connect payment", + // TODO: Add "Your payment method is connected" / "Din betalmetod är kopplad" to Lokalise + description = "Your payment method is connected", + ) + Spacer(Modifier.weight(1f)) + Icon( + imageVector = HedvigIcons.Checkmark, + contentDescription = null, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + Spacer(Modifier.height(16.dp)) + // TODO: Add "You can switch accounts later in settings" / + // "Du kan byta konto senare i inställningar" to Lokalise + HedvigText( + text = "You can switch accounts later in settings", + color = HedvigTheme.colorScheme.textSecondary, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + OnboardingStepButtons( + // TODO: Add "Continue" / "Fortsätt" to Lokalise + primaryText = "Continue", + onPrimaryClick = { viewModel.emit(OnboardingPaymentEvent.Continue) }, + ) + } + } +``` + +- [ ] **Step 5: Run tests, format, commit** + +```bash +./gradlew :feature-onboarding:testDebugUnitTest 2>&1 | grep -E "(BUILD SUCCESSFUL|FAILED)" +./gradlew ktlintFormat 2>&1 | grep -E "BUILD (SUCCESSFUL|FAILED)" +git add app/feature/feature-onboarding +git commit -m "Add onboarding connect payment step" +``` + +--- + +### Task 18: Bundle discount step + +**Files:** +- Create: `app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt` +- Test: `app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundlePresenterTest.kt` + +**Interfaces:** +- Consumes: shared conventions, `OnboardingCrossSell` list from the session. +- Produces: `OnboardingBundleViewModel(sessionStore, navigator)`, `OnboardingBundleDestination(viewModel, navigateUp, openUrl: (String) -> Unit)`. `openUrl` is threaded from `:app` in Task 19 (external browser via the app's uri handler). + +Figma: title "Get bundle discount", body "You get a 15% bundle discount when you have two or more insurances with us", one row per cross sell (title + description, trailing "See price" text button opening `storeUrl`), primary "Continue to app" which completes the whole flow. + +- [ ] **Step 1: Write the failing presenter test** + +Cases: + +1. `content lists the cross sells`: default test data has one cross sell; assert `Content.crossSells.single().title == "Pet"`. +2. `continue to app completes onboarding`: BundleDiscount is the last step for default data, so `sendEvent(ContinueToApp)` must invoke the complete-use-case and clear all onboarding keys. Use a recording `FakeCompleteOnboardingUseCase` (as in Task 8's test) and a backstack seeded with `[NonOnboardingKey, OnboardingKey, OnboardingStepKey(BundleDiscount)]`; assert `completeOnboarding.invoked` is true and entries == `[NonOnboardingKey]`. + +- [ ] **Step 2: Run to verify failure** (same command) + +- [ ] **Step 3: Implement** + +State/events: + +```kotlin +internal sealed interface OnboardingBundleUiState { + data object Loading : OnboardingBundleUiState + + data object Error : OnboardingBundleUiState + + data class Content( + val progress: OnboardingProgress, + val crossSells: List, + ) : OnboardingBundleUiState +} + +internal sealed interface OnboardingBundleEvent { + data object Retry : OnboardingBundleEvent + + data object Close : OnboardingBundleEvent + + data object ContinueToApp : OnboardingBundleEvent +} +``` + +Presenter: content maps `crossSells = session.data.crossSells`; `ContinueToApp -> launch { navigator.continueFrom(OnboardingStepId.BundleDiscount) }` (as the last path step, `continueFrom` exits and marks seen; if a future path ever orders another step after it, this still just advances, which is correct). + +Destination `Content` branch: header ("Get bundle discount" / "You get a bundle discount when you have two or more insurances with us", Lokalise TODOs; the Figma "15%" figure is not available from `otherCrossSells`, so the copy stays unquantified until backend exposes it, noted in follow-ups), then rows: + +```kotlin + for (crossSell in content.crossSells) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Column(Modifier.weight(1f)) { + HedvigText(crossSell.title) + HedvigText(crossSell.description, color = HedvigTheme.colorScheme.textSecondary) + } + HedvigTextButton( + // TODO: Add "See price" / "Se pris" to Lokalise + text = "See price", + onClick = { openUrl(crossSell.storeUrl) }, + ) + } + } +``` + +Then `OnboardingStepButtons(primaryText = "Continue to app" /* TODO Lokalise: "Fortsätt till appen" */, onPrimaryClick = { viewModel.emit(OnboardingBundleEvent.ContinueToApp) })`. + +- [ ] **Step 4: Run tests, format, commit** + +```bash +./gradlew :feature-onboarding:testDebugUnitTest 2>&1 | grep -E "(BUILD SUCCESSFUL|FAILED)" +./gradlew ktlintFormat 2>&1 | grep -E "BUILD (SUCCESSFUL|FAILED)" +git add app/feature/feature-onboarding +git commit -m "Add onboarding bundle discount step" +``` + +--- + +### Task 19: Entries registration, gate, and :app wiring + +**Files:** +- Create: `app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingEntries.kt` +- Create: `app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/gate/OnboardingGate.kt` +- Modify: `app/app/build.gradle.kts` +- Modify: `app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigEntryProvider.kt` +- Modify: `app/app/src/main/kotlin/com/hedvig/android/app/di/ActivityRetainedGraph.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` (or wherever `HedvigApp(...)` is invoked; follow how `backstackController` reaches it) + +**Interfaces:** +- Consumes: everything produced by Tasks 4-18; `MemberIdService`, `AuthTokenService.authStatus`/`AuthStatus.LoggedIn`, `BackstackController.currentDestination`/`pendingDeepLink` (both accessible inside `:app`), `HomeKey` (import from the home navigation module; grep `HomeKey` in `HedvigEntryProvider.kt` for the exact import), `ChipIdKey` (`com.hedvig.android.feature.chip.id.navigation`). +- Produces: `fun EntryProviderScope.onboardingEntries(backstack, openUrl, openPrivacyPolicy, navigateToChipId)`; `interface OnboardingGate { suspend fun shouldShowOnboarding(): Boolean }` bound in `ActivityRetainedScope`. + +- [ ] **Step 1: Create the gate** + +`OnboardingGate.kt`: + +```kotlin +package com.hedvig.android.feature.onboarding.gate + +import com.hedvig.android.auth.MemberIdService +import com.hedvig.android.core.common.di.ActivityRetainedScope +import com.hedvig.android.feature.onboarding.data.OnboardingSeenStore +import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore +import com.hedvig.android.logger.LogPriority +import com.hedvig.android.logger.logcat +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import kotlinx.coroutines.flow.first + +/** + * Decides whether onboarding should be shown for the current member: never seen before, and the + * eager fetch succeeded (caching the session so the flow renders without further loading). On + * fetch failure this returns false WITHOUT marking seen, so the next app start retries. + */ +interface OnboardingGate { + suspend fun shouldShowOnboarding(): Boolean +} + +@ContributesBinding(ActivityRetainedScope::class) +@SingleIn(ActivityRetainedScope::class) +@Inject +internal class OnboardingGateImpl( + private val memberIdService: MemberIdService, + private val onboardingSeenStore: OnboardingSeenStore, + private val sessionStore: OnboardingSessionStore, +) : OnboardingGate { + override suspend fun shouldShowOnboarding(): Boolean { + val memberId = memberIdService.getMemberId().first() ?: return false + if (onboardingSeenStore.hasSeenOnboarding(memberId)) return false + return sessionStore.getOrFetchSession().fold( + ifLeft = { errorMessage -> + logcat(LogPriority.INFO) { "Onboarding data fetch failed, not showing onboarding: $errorMessage" } + false + }, + ifRight = { session -> session.path.isNotEmpty() }, + ) + } +} +``` + +No dedicated unit test: every branch's collaborator (`OnboardingSeenStore`, `OnboardingSessionStore`, path builder) is already unit tested, and the composition is four lines around a concrete `MemberIdService` that has no in-repo fake. Covered by the manual QA pass in Task 20. + +- [ ] **Step 2: Create the entries function** + +`OnboardingEntries.kt`: + +```kotlin +package com.hedvig.android.feature.onboarding.navigation + +import androidx.navigation3.runtime.EntryProviderScope +import com.hedvig.android.feature.onboarding.ui.bundle.OnboardingBundleDestination +import com.hedvig.android.feature.onboarding.ui.bundle.OnboardingBundleViewModel +import com.hedvig.android.feature.onboarding.ui.coinsured.OnboardingCoInsuredDestination +import com.hedvig.android.feature.onboarding.ui.coinsured.OnboardingCoInsuredViewModel +import com.hedvig.android.feature.onboarding.ui.consent.OnboardingConsentDestination +import com.hedvig.android.feature.onboarding.ui.consent.OnboardingConsentViewModel +import com.hedvig.android.feature.onboarding.ui.invite.OnboardingInviteDestination +import com.hedvig.android.feature.onboarding.ui.invite.OnboardingInviteViewModel +import com.hedvig.android.feature.onboarding.ui.payment.OnboardingPaymentDestination +import com.hedvig.android.feature.onboarding.ui.payment.OnboardingPaymentViewModel +import com.hedvig.android.feature.onboarding.ui.petid.OnboardingPetIdDestination +import com.hedvig.android.feature.onboarding.ui.petid.OnboardingPetIdViewModel +import com.hedvig.android.feature.onboarding.ui.phone.OnboardingPhoneDestination +import com.hedvig.android.feature.onboarding.ui.phone.OnboardingPhoneViewModel +import com.hedvig.android.feature.onboarding.ui.theme.OnboardingThemeDestination +import com.hedvig.android.feature.onboarding.ui.theme.OnboardingThemeViewModel +import com.hedvig.android.feature.onboarding.ui.welcome.OnboardingWelcomeDestination +import com.hedvig.android.feature.onboarding.ui.welcome.OnboardingWelcomeViewModel +import com.hedvig.android.navigation.common.HedvigNavKey +import com.hedvig.android.navigation.compose.Backstack +import dev.zacsweers.metrox.viewmodel.metroViewModel + +fun EntryProviderScope.onboardingEntries( + backstack: Backstack, + openUrl: (String) -> Unit, + openPrivacyPolicy: () -> Unit, + navigateToChipId: (contractId: String) -> Unit, +) { + entry { + val viewModel: OnboardingWelcomeViewModel = metroViewModel() + OnboardingWelcomeDestination(viewModel) + } + + entry { key -> + when (key.stepId) { + OnboardingStepId.AnalyticsConsent -> { + val viewModel: OnboardingConsentViewModel = metroViewModel() + OnboardingConsentDestination(viewModel, backstack::navigateUp, openPrivacyPolicy) + } + OnboardingStepId.PhoneNumber -> { + val viewModel: OnboardingPhoneViewModel = metroViewModel() + OnboardingPhoneDestination(viewModel, backstack::navigateUp) + } + OnboardingStepId.Theme -> { + val viewModel: OnboardingThemeViewModel = metroViewModel() + OnboardingThemeDestination(viewModel, backstack::navigateUp) + } + OnboardingStepId.CoInsured -> { + val viewModel: OnboardingCoInsuredViewModel = metroViewModel() + OnboardingCoInsuredDestination(viewModel, backstack::navigateUp) + } + OnboardingStepId.PetIds -> { + val viewModel: OnboardingPetIdViewModel = metroViewModel() + OnboardingPetIdDestination(viewModel, backstack::navigateUp, navigateToChipId) + } + OnboardingStepId.InviteFriend -> { + val viewModel: OnboardingInviteViewModel = metroViewModel() + OnboardingInviteDestination(viewModel, backstack::navigateUp) + } + OnboardingStepId.ConnectPayment -> { + val viewModel: OnboardingPaymentViewModel = metroViewModel() + OnboardingPaymentDestination(viewModel, backstack::navigateUp) + } + OnboardingStepId.BundleDiscount -> { + val viewModel: OnboardingBundleViewModel = metroViewModel() + OnboardingBundleDestination(viewModel, backstack::navigateUp, openUrl) + } + } + } +} +``` + +(No `NavSuiteSceneDecoratorStrategy.showNavBar()` metadata: onboarding entries are full-screen without the bottom nav, which is the default for entries without that metadata.) + +- [ ] **Step 3: Wire into :app** + +1. `app/app/build.gradle.kts`: add `implementation(projects.featureOnboarding)` to the dependencies block (alphabetical position). +2. `HedvigEntryProvider.kt`: import `com.hedvig.android.feature.onboarding.navigation.onboardingEntries` and call it where the other shared-flow entries are registered (`addSharedFlowEntries` is a fine home): + +```kotlin + onboardingEntries( + backstack = backstack, + openUrl = openUrl, + openPrivacyPolicy = { openUrl(hedvigBuildConstants.urlPrivacyPolicy) }, + navigateToChipId = { contractId -> backstack.add(ChipIdKey(contractId)) }, + ) +``` + +`hedvigBuildConstants.urlPrivacyPolicy`: check `HedvigBuildConstants` for an existing privacy-policy URL property; if none exists, grep `feature-profile`/`feature-login` for the privacy policy URL they open and reuse that exact constant or string. Thread whatever parameters (`openUrl`, `hedvigBuildConstants`) the chosen registration site already has; `hedvigEntryProvider`'s signature already includes both. + +3. `ActivityRetainedGraph.kt`: add an accessor so `:app` can hand the gate to the effect: + +```kotlin + val onboardingGate: OnboardingGate +``` + +(import `com.hedvig.android.feature.onboarding.gate.OnboardingGate`). + +4. `HedvigApp.kt`: add `onboardingGate: OnboardingGate` to the `HedvigApp(...)` parameter list; pass it at the call site the same way `backstackController` gets there (both live on the `ActivityRetainedGraph`, read in `MainActivity` via `navRetainedViewModel`). Next to `TryShowAppStoreReviewDialogEffect(...)`, add: + +```kotlin + TryShowOnboardingEffect( + authTokenService = authTokenService, + onboardingGate = onboardingGate, + backstackController = backstackController, + ) +``` + +And the effect definition next to `TryShowAppStoreReviewDialogEffect`: + +```kotlin +@Composable +private fun TryShowOnboardingEffect( + authTokenService: AuthTokenService, + onboardingGate: OnboardingGate, + backstackController: BackstackController, +) { + val lifecycle = LocalLifecycleOwner.current.lifecycle + LaunchedEffect(lifecycle) { + lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { + authTokenService.authStatus.first { it is AuthStatus.LoggedIn } + // Already showing (e.g. resumed mid-flow): nothing to do this pass. + if (backstackController.entries.any { it is OnboardingKey || it is OnboardingStepKey }) { + return@repeatOnLifecycle + } + if (!onboardingGate.shouldShowOnboarding()) return@repeatOnLifecycle + // Only interrupt an idle Home root, never a deep-linked or in-progress flow. + snapshotFlow { backstackController.currentDestination to backstackController.pendingDeepLink } + .first { (currentDestination, pendingDeepLink) -> + currentDestination is HomeKey && pendingDeepLink == null + } + backstackController.add(OnboardingKey) + } + } +} +``` + +`OnboardingStepKey` is `internal` to the feature module, so the `it is OnboardingStepKey` check cannot compile in `:app`; drop it and keep only `it is OnboardingKey` (the root key is always present while the flow is up, since steps are pushed on top of it). Adjust the comment accordingly. Imports mirror `TryShowAppStoreReviewDialogEffect`'s (`AuthStatus`, `first`, `repeatOnLifecycle`, `LocalLifecycleOwner`, plus `androidx.compose.runtime.snapshotFlow`, `com.hedvig.android.feature.onboarding.navigation.OnboardingKey`, and the `HomeKey` import already used elsewhere in `:app`). + +- [ ] **Step 4: Verify the graph, serialization test, and full app build** + +```bash +./gradlew :app:testDevelopDebugUnitTest --tests "*ExhaustiveBackstackSerializationTest*" 2>&1 | grep -E "(BUILD SUCCESSFUL|FAILED|error:)" | head -5 +./gradlew :app:assembleDevelopDebug 2>&1 | grep -E "(BUILD SUCCESSFUL|FAILED|error:)" | head -5 +``` + +Expected: both `BUILD SUCCESSFUL`. (If the test task name differs, run `./gradlew :app:tasks --all | grep -i test | head` and pick the develop-debug unit test task.) The serialization test proves both new keys round-trip via the `navKeys()`-generated `SerializersModule`. A Metro error about `OnboardingGate` here means the `@ContributesBinding(ActivityRetainedScope::class)` contribution isn't visible; re-check the interface is `public` (see the project memory: internal cross-module entry points compile but fail). + +- [ ] **Step 5: Format and commit** + +```bash +./gradlew ktlintFormat 2>&1 | grep -E "BUILD (SUCCESSFUL|FAILED)" +git add app/feature/feature-onboarding app/app +git commit -m "Register onboarding entries and gate the flow after login" +``` + +--- + +### Task 20: Full verification sweep and manual QA + +**Files:** none created; fixes only if verification fails. + +- [ ] **Step 1: Full unit test run** + +```bash +./gradlew test 2>&1 | tail -30 +``` + +Grep the full output for `BUILD SUCCESSFUL` explicitly (do not trust the piped exit code). Fix any failures before proceeding. + +- [ ] **Step 2: Lint and format check** + +```bash +./gradlew ktlintCheck 2>&1 | grep -E "BUILD (SUCCESSFUL|FAILED)" +./gradlew :feature-onboarding:lint 2>&1 | grep -E "BUILD (SUCCESSFUL|FAILED)" +``` + +- [ ] **Step 3: Manual QA on a develop build (staging backend)** + +Install `:app:assembleDevelopDebug` on a device/emulator and verify: + +1. Fresh login with a member who has never seen onboarding: after landing on Home, the flow appears on top with the progress bar sized to the member's path. +2. Back arrow pops one step; system back gesture matches it (predictive back included). +3. X on any step returns to Home; killing and relaunching the app does NOT show onboarding again (per-member seen flag). +4. Airplane mode before first login: no onboarding, no crash; disable airplane mode, restart the app: onboarding appears (fetch failure did not mark seen). +5. Co-insured "Add" opens the edit-co-insured flow; finishing it returns to the step with the row checked. +6. "Connect payment" opens Trustly; returning shows the connected variant after refresh. +7. Process death mid-flow (`adb shell am kill com.hedvig.dev.app` while backgrounded on a step): reopening restores the step, shows loading, refetches, and renders. +8. Deny consent, then trigger app actions: verify no Firebase Analytics events leave (Firebase DebugView), while Datadog RUM still reports. Then grant consent in Settings and verify buffered/live events arrive. +9. Deep link into the app (e.g. a contract deep link) with an unseen member: the deep-linked screen is NOT interrupted by onboarding; onboarding appears on a later idle-Home resume. + +- [ ] **Step 4: Update follow-ups and finish** + +Record these known follow-ups in the PR description (all deliberately out of v1 scope): + +- Figma animations (welcome badge, consent checkmark, phone number pad, payment dots/checkmark). +- Replace every hardcoded string once the Lokalise keys exist (`grep -rn "TODO: Add" app/feature/feature-onboarding app/feature/feature-profile app/tracking` to enumerate). +- Bundle-discount percentage copy once backend exposes the figure. +- Confirm the `crossSellV2` `userFlow` input value and share-message copy with product. +- Consider persisting the consent event buffer across process death (in-memory in v1). +- Spec deviation (deliberate simplification): when a process-death refetch rebuilds a path that no + longer contains the restored step, the spec suggested popping to the nearest preceding step; v1 + instead keeps the step visible and degrades its progress indicator (see `progressFor`). Revisit + if it ever surfaces in practice. + +Then run the finishing flow (`superpowers:finishing-a-development-branch`): final review, PR against `develop`. From fb6023954fa5b90d553c933c14194688b58c911d Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 21 Jul 2026 15:09:45 +0200 Subject: [PATCH 003/119] Add tri-state AnalyticsConsent to SettingsDataStore Introduces a tri-state AnalyticsConsent enum (NOT_DECIDED, GRANTED, DENIED) to the SettingsDataStore for controlling product analytics consent. Includes setAnalyticsConsent and observeAnalyticsConsent methods with full test coverage. --- .../build.gradle.kts | 3 ++ .../settings/datastore/AnalyticsConsent.kt | 13 ++++++ .../datastore/SettingsDataStoreImpl.kt | 25 ++++++++++++ .../datastore/SettingsDataStoreImplTest.kt | 40 +++++++++++++++++++ 4 files changed, 81 insertions(+) create mode 100644 app/data/data-settings-datastore/data-settings-datastore-public/src/main/kotlin/com/hedvig/android/data/settings/datastore/AnalyticsConsent.kt create mode 100644 app/data/data-settings-datastore/data-settings-datastore-public/src/test/kotlin/com/hedvig/android/data/settings/datastore/SettingsDataStoreImplTest.kt diff --git a/app/data/data-settings-datastore/data-settings-datastore-public/build.gradle.kts b/app/data/data-settings-datastore/data-settings-datastore-public/build.gradle.kts index d10e19cd48..685eacb8b0 100644 --- a/app/data/data-settings-datastore/data-settings-datastore-public/build.gradle.kts +++ b/app/data/data-settings-datastore/data-settings-datastore-public/build.gradle.kts @@ -9,4 +9,7 @@ dependencies { implementation(libs.coroutines.core) implementation(projects.coreCommonPublic) implementation(projects.theme) + testImplementation(libs.assertK) + testImplementation(libs.coroutines.test) + testImplementation(libs.junit) } diff --git a/app/data/data-settings-datastore/data-settings-datastore-public/src/main/kotlin/com/hedvig/android/data/settings/datastore/AnalyticsConsent.kt b/app/data/data-settings-datastore/data-settings-datastore-public/src/main/kotlin/com/hedvig/android/data/settings/datastore/AnalyticsConsent.kt new file mode 100644 index 0000000000..74385fa8a0 --- /dev/null +++ b/app/data/data-settings-datastore/data-settings-datastore-public/src/main/kotlin/com/hedvig/android/data/settings/datastore/AnalyticsConsent.kt @@ -0,0 +1,13 @@ +package com.hedvig.android.data.settings.datastore + +/** + * Consent for product analytics (Firebase Analytics only; Datadog performance/bug analytics is + * intentionally not covered by this and stays always-on). + * [NOT_DECIDED] means the member has never been asked or never answered: events are buffered + * in-app, not forwarded, until an explicit decision is made. + */ +enum class AnalyticsConsent { + NOT_DECIDED, + GRANTED, + DENIED, +} diff --git a/app/data/data-settings-datastore/data-settings-datastore-public/src/main/kotlin/com/hedvig/android/data/settings/datastore/SettingsDataStoreImpl.kt b/app/data/data-settings-datastore/data-settings-datastore-public/src/main/kotlin/com/hedvig/android/data/settings/datastore/SettingsDataStoreImpl.kt index 03406582ab..43bfdeb32b 100644 --- a/app/data/data-settings-datastore/data-settings-datastore-public/src/main/kotlin/com/hedvig/android/data/settings/datastore/SettingsDataStoreImpl.kt +++ b/app/data/data-settings-datastore/data-settings-datastore-public/src/main/kotlin/com/hedvig/android/data/settings/datastore/SettingsDataStoreImpl.kt @@ -24,6 +24,14 @@ interface SettingsDataStore { suspend fun setEmailSubscriptionPreference(subscribe: Boolean) fun observeEmailSubscriptionPreference(): Flow + + suspend fun setAnalyticsConsent(consent: AnalyticsConsent) + + /** + * The member's product analytics consent decision. [AnalyticsConsent.NOT_DECIDED] when they + * have not made an explicit choice yet. + */ + fun observeAnalyticsConsent(): Flow } @ContributesBinding(AppScope::class) @@ -57,10 +65,27 @@ internal class SettingsDataStoreImpl( } } + override suspend fun setAnalyticsConsent(consent: AnalyticsConsent) { + dataStore.edit { + it[analyticsConsentKey] = consent.name + } + } + + override fun observeAnalyticsConsent(): Flow { + return dataStore.data.map { preferences -> + preferences[analyticsConsentKey] + ?.let { stored -> AnalyticsConsent.entries.firstOrNull { it.name == stored } } + ?: AnalyticsConsent.NOT_DECIDED + } + } + companion object { private val themeKey = stringPreferencesKey("settings-theme") private val subscriptionKey = booleanPreferencesKey( "com.hedvig.android.data.settings.datastore.settings-email-subscription", ) + private val analyticsConsentKey = stringPreferencesKey( + "com.hedvig.android.data.settings.datastore.settings-analytics-consent", + ) } } diff --git a/app/data/data-settings-datastore/data-settings-datastore-public/src/test/kotlin/com/hedvig/android/data/settings/datastore/SettingsDataStoreImplTest.kt b/app/data/data-settings-datastore/data-settings-datastore-public/src/test/kotlin/com/hedvig/android/data/settings/datastore/SettingsDataStoreImplTest.kt new file mode 100644 index 0000000000..49a9dd9d5b --- /dev/null +++ b/app/data/data-settings-datastore/data-settings-datastore-public/src/test/kotlin/com/hedvig/android/data/settings/datastore/SettingsDataStoreImplTest.kt @@ -0,0 +1,40 @@ +package com.hedvig.android.data.settings.datastore + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import assertk.assertThat +import assertk.assertions.isEqualTo +import java.io.File +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toPath +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class SettingsDataStoreImplTest { + @get:Rule + val temporaryFolder: TemporaryFolder = TemporaryFolder() + + // Note: pass a path that does NOT exist yet; DataStore treats a pre-created empty file as corrupt. + private fun settingsDataStore(fileName: String): SettingsDataStoreImpl { + val file = File(temporaryFolder.root, fileName) + return SettingsDataStoreImpl( + PreferenceDataStoreFactory.createWithPath(produceFile = { file.absolutePath.toPath() }), + ) + } + + @Test + fun `analytics consent defaults to NOT_DECIDED when nothing is stored`() = runTest { + val store = settingsDataStore("settings1.preferences_pb") + assertThat(store.observeAnalyticsConsent().first()).isEqualTo(AnalyticsConsent.NOT_DECIDED) + } + + @Test + fun `analytics consent round-trips GRANTED and DENIED`() = runTest { + val store = settingsDataStore("settings2.preferences_pb") + store.setAnalyticsConsent(AnalyticsConsent.GRANTED) + assertThat(store.observeAnalyticsConsent().first()).isEqualTo(AnalyticsConsent.GRANTED) + store.setAnalyticsConsent(AnalyticsConsent.DENIED) + assertThat(store.observeAnalyticsConsent().first()).isEqualTo(AnalyticsConsent.DENIED) + } +} From 1f5fe8ff30ece777160689fa0a98c095e5e06475 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 21 Jul 2026 15:31:55 +0200 Subject: [PATCH 004/119] Gate Firebase Analytics behind tri-state consent with buffer-and-flush Adds ConsentAwareEventTrackingClient which wraps FirebaseEventTrackingClient: NOT_DECIDED buffers up to 200 events in-memory, GRANTED flushes them (stamping buffered_at_epoch_ms) and forwards live, DENIED drops all. Identity calls (setUserId, setUserProperty) and setCollectionEnabled pass through unconditionally. Datadog is intentionally untouched. Removes @ContributesBinding from FirebaseEventTrackingClient and supplies the EventTrackingClient binding via a @ContributesTo @Provides interface to avoid a self-loop in the DI graph. --- .../tracking-firebase/build.gradle.kts | 7 + .../ConsentAwareEventTrackingClient.kt | 170 ++++++++++++++++++ .../firebase/FirebaseEventTrackingClient.kt | 2 - .../ConsentAwareEventTrackingClientTest.kt | 126 +++++++++++++ 4 files changed, 303 insertions(+), 2 deletions(-) create mode 100644 app/tracking/tracking-firebase/src/main/kotlin/com/hedvig/android/tracking/firebase/ConsentAwareEventTrackingClient.kt create mode 100644 app/tracking/tracking-firebase/src/test/kotlin/com/hedvig/android/tracking/firebase/ConsentAwareEventTrackingClientTest.kt diff --git a/app/tracking/tracking-firebase/build.gradle.kts b/app/tracking/tracking-firebase/build.gradle.kts index 9ead318b44..e5568e0638 100644 --- a/app/tracking/tracking-firebase/build.gradle.kts +++ b/app/tracking/tracking-firebase/build.gradle.kts @@ -10,6 +10,13 @@ dependencies { implementation(projects.authCorePublic) implementation(projects.coreCommonPublic) implementation(projects.coreDemoMode) + implementation(projects.dataSettingsDatastorePublic) implementation(projects.initializable) implementation(projects.trackingCore) + testImplementation(libs.assertK) + testImplementation(libs.coroutines.test) + testImplementation(libs.junit) + testImplementation(libs.turbine) + testImplementation(projects.loggingTest) + testImplementation(projects.theme) } diff --git a/app/tracking/tracking-firebase/src/main/kotlin/com/hedvig/android/tracking/firebase/ConsentAwareEventTrackingClient.kt b/app/tracking/tracking-firebase/src/main/kotlin/com/hedvig/android/tracking/firebase/ConsentAwareEventTrackingClient.kt new file mode 100644 index 0000000000..76debff022 --- /dev/null +++ b/app/tracking/tracking-firebase/src/main/kotlin/com/hedvig/android/tracking/firebase/ConsentAwareEventTrackingClient.kt @@ -0,0 +1,170 @@ +package com.hedvig.android.tracking.firebase + +import com.hedvig.android.core.common.ApplicationScope +import com.hedvig.android.core.common.di.AppScope +import com.hedvig.android.core.common.di.IoDispatcher +import com.hedvig.android.core.tracking.EventTrackingClient +import com.hedvig.android.data.settings.datastore.AnalyticsConsent +import com.hedvig.android.data.settings.datastore.SettingsDataStore +import com.hedvig.android.logger.logcat +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.Provides +import dev.zacsweers.metro.SingleIn +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** + * Gates product analytics behind the member's [AnalyticsConsent]: + * - [AnalyticsConsent.NOT_DECIDED]: events/screens are buffered in-memory (bounded), not forwarded. + * - [AnalyticsConsent.GRANTED]: the buffer is flushed (stamped with `buffered_at_epoch_ms`) and + * subsequent events forward live. + * - [AnalyticsConsent.DENIED]: the buffer is dropped and nothing is forwarded. + * Identity calls ([setUserId], [setUserProperty]) and [setCollectionEnabled] (demo-mode gating) + * always pass through. Datadog is untouched by design: it carries performance and bug analytics, + * not product analytics. + * + * Deliberately NOT annotated with Metro annotations: annotating it `@ContributesBinding` for + * [EventTrackingClient] while also injecting an [EventTrackingClient] delegate would self-loop. + * The binding is supplied by [ConsentAwareEventTrackingClientProviders] below, which hands in the + * concrete [FirebaseEventTrackingClient] as the delegate. + */ +internal class ConsentAwareEventTrackingClient( + private val delegate: EventTrackingClient, + private val settingsDataStore: SettingsDataStore, + applicationScope: CoroutineScope, + @IoDispatcher coroutineContext: CoroutineContext, + private val clock: () -> Long = { System.currentTimeMillis() }, +) : EventTrackingClient { + private val lock = Any() + private var consent: AnalyticsConsent = AnalyticsConsent.NOT_DECIDED + private val buffer = ArrayDeque() + + init { + applicationScope.launch(coroutineContext) { + settingsDataStore.observeAnalyticsConsent().collect { newConsent -> + val toFlush: List = synchronized(lock) { + consent = newConsent + when (newConsent) { + AnalyticsConsent.GRANTED -> { + buffer.toList().also { buffer.clear() } + } + + AnalyticsConsent.DENIED -> { + buffer.clear() + emptyList() + } + + AnalyticsConsent.NOT_DECIDED -> { + emptyList() + } + } + } + if (toFlush.isNotEmpty()) { + logcat { "Analytics consent granted, flushing ${toFlush.size} buffered events" } + for (call in toFlush) { + call.forwardTo(delegate) + } + } + } + } + } + + override fun setCollectionEnabled(enabled: Boolean) { + delegate.setCollectionEnabled(enabled) + } + + override fun trackEvent(name: String, parameters: Map) { + handle(BufferedCall.Event(name, parameters, clock())) + } + + override fun trackScreen(name: String, screenClass: String?, parameters: Map) { + handle(BufferedCall.Screen(name, screenClass, parameters, clock())) + } + + override fun setUserId(userId: String?) { + delegate.setUserId(userId) + } + + override fun setUserProperty(name: String, value: String?) { + delegate.setUserProperty(name, value) + } + + private fun handle(call: BufferedCall) { + val action: Action = synchronized(lock) { + when (consent) { + AnalyticsConsent.GRANTED -> { + Action.Forward + } + + AnalyticsConsent.DENIED -> { + Action.Drop + } + + AnalyticsConsent.NOT_DECIDED -> { + buffer.addLast(call) + while (buffer.size > MAX_BUFFERED_CALLS) { + buffer.removeFirst() + } + Action.Drop + } + } + } + if (action == Action.Forward) { + call.forwardTo(delegate, includeBufferedAt = false) + } + } + + private enum class Action { Forward, Drop } + + private sealed interface BufferedCall { + val bufferedAtEpochMs: Long + + fun forwardTo(client: EventTrackingClient, includeBufferedAt: Boolean = true) + + data class Event( + val name: String, + val parameters: Map, + override val bufferedAtEpochMs: Long, + ) : BufferedCall { + override fun forwardTo(client: EventTrackingClient, includeBufferedAt: Boolean) { + val params = if (includeBufferedAt) parameters + ("buffered_at_epoch_ms" to bufferedAtEpochMs) else parameters + client.trackEvent(name, params) + } + } + + data class Screen( + val name: String, + val screenClass: String?, + val parameters: Map, + override val bufferedAtEpochMs: Long, + ) : BufferedCall { + override fun forwardTo(client: EventTrackingClient, includeBufferedAt: Boolean) { + val params = if (includeBufferedAt) parameters + ("buffered_at_epoch_ms" to bufferedAtEpochMs) else parameters + client.trackScreen(name, screenClass, params) + } + } + } + + companion object { + private const val MAX_BUFFERED_CALLS = 200 + } +} + +@ContributesTo(AppScope::class) +@Suppress("EXPOSED_PARAMETER_TYPE") +interface ConsentAwareEventTrackingClientProviders { + @Provides + @SingleIn(AppScope::class) + fun provideEventTrackingClient( + firebaseEventTrackingClient: FirebaseEventTrackingClient, + settingsDataStore: SettingsDataStore, + applicationScope: ApplicationScope, + @IoDispatcher coroutineContext: CoroutineContext, + ): EventTrackingClient = ConsentAwareEventTrackingClient( + delegate = firebaseEventTrackingClient, + settingsDataStore = settingsDataStore, + applicationScope = applicationScope, + coroutineContext = coroutineContext, + ) +} diff --git a/app/tracking/tracking-firebase/src/main/kotlin/com/hedvig/android/tracking/firebase/FirebaseEventTrackingClient.kt b/app/tracking/tracking-firebase/src/main/kotlin/com/hedvig/android/tracking/firebase/FirebaseEventTrackingClient.kt index 3b2cb3fca6..784120621b 100644 --- a/app/tracking/tracking-firebase/src/main/kotlin/com/hedvig/android/tracking/firebase/FirebaseEventTrackingClient.kt +++ b/app/tracking/tracking-firebase/src/main/kotlin/com/hedvig/android/tracking/firebase/FirebaseEventTrackingClient.kt @@ -6,11 +6,9 @@ import android.os.Bundle import com.google.firebase.analytics.FirebaseAnalytics import com.hedvig.android.core.common.di.AppScope import com.hedvig.android.core.tracking.EventTrackingClient -import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn -@ContributesBinding(AppScope::class) @SingleIn(AppScope::class) @Inject internal class FirebaseEventTrackingClient( diff --git a/app/tracking/tracking-firebase/src/test/kotlin/com/hedvig/android/tracking/firebase/ConsentAwareEventTrackingClientTest.kt b/app/tracking/tracking-firebase/src/test/kotlin/com/hedvig/android/tracking/firebase/ConsentAwareEventTrackingClientTest.kt new file mode 100644 index 0000000000..c89591795f --- /dev/null +++ b/app/tracking/tracking-firebase/src/test/kotlin/com/hedvig/android/tracking/firebase/ConsentAwareEventTrackingClientTest.kt @@ -0,0 +1,126 @@ +package com.hedvig.android.tracking.firebase + +import assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo +import assertk.assertions.key +import com.hedvig.android.core.tracking.EventTrackingClient +import com.hedvig.android.data.settings.datastore.AnalyticsConsent +import com.hedvig.android.data.settings.datastore.SettingsDataStore +import com.hedvig.android.logger.TestLogcatLoggingRule +import com.hedvig.android.theme.Theme +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class ConsentAwareEventTrackingClientTest { + @get:Rule + val testLogcatLogger = TestLogcatLoggingRule() + + private class RecordingClient : EventTrackingClient { + val events = mutableListOf>>() + val screens = mutableListOf() + var recordedUserId: String? = null + + override fun setCollectionEnabled(enabled: Boolean) {} + + override fun trackEvent(name: String, parameters: Map) { + events.add(name to parameters) + } + + override fun trackScreen(name: String, screenClass: String?, parameters: Map) { + screens.add(name) + } + + override fun setUserId(userId: String?) { + recordedUserId = userId + } + + override fun setUserProperty(name: String, value: String?) {} + } + + private class FakeSettingsDataStore( + val consent: MutableStateFlow = MutableStateFlow(AnalyticsConsent.NOT_DECIDED), + ) : SettingsDataStore { + override suspend fun setTheme(theme: Theme) = error("unused") + + override fun observeTheme(): Flow = error("unused") + + override suspend fun setEmailSubscriptionPreference(subscribe: Boolean) = error("unused") + + override fun observeEmailSubscriptionPreference(): Flow = error("unused") + + override suspend fun setAnalyticsConsent(newConsent: AnalyticsConsent) { + consent.value = newConsent + } + + override fun observeAnalyticsConsent(): Flow = consent + } + + private fun TestScope.client( + recording: RecordingClient, + settings: FakeSettingsDataStore, + ): ConsentAwareEventTrackingClient { + return ConsentAwareEventTrackingClient( + delegate = recording, + settingsDataStore = settings, + applicationScope = backgroundScope, + coroutineContext = StandardTestDispatcher(testScheduler), + clock = { 1234L }, + ) + } + + @Test + fun `while NOT_DECIDED events are buffered, not forwarded`() = runTest { + val recording = RecordingClient() + val client = client(recording, FakeSettingsDataStore()) + runCurrent() + client.trackEvent("clicked_thing", mapOf("k" to "v")) + runCurrent() + assertThat(recording.events).isEmpty() + } + + @Test + fun `granting consent flushes buffered events with buffered_at_epoch_ms and forwards live ones`() = runTest { + val recording = RecordingClient() + val settings = FakeSettingsDataStore() + val client = client(recording, settings) + runCurrent() + client.trackEvent("early_event", emptyMap()) + settings.consent.value = AnalyticsConsent.GRANTED + runCurrent() + assertThat(recording.events.map { it.first }).containsExactly("early_event") + assertThat(recording.events.single().second).key("buffered_at_epoch_ms").isEqualTo(1234L) + client.trackEvent("live_event", emptyMap()) + assertThat(recording.events.map { it.first }).containsExactly("early_event", "live_event") + } + + @Test + fun `denying consent drops the buffer and all subsequent events`() = runTest { + val recording = RecordingClient() + val settings = FakeSettingsDataStore() + val client = client(recording, settings) + runCurrent() + client.trackEvent("early_event", emptyMap()) + settings.consent.value = AnalyticsConsent.DENIED + runCurrent() + client.trackEvent("post_denial_event", emptyMap()) + runCurrent() + assertThat(recording.events).isEmpty() + } + + @Test + fun `setUserId passes through regardless of consent`() = runTest { + val recording = RecordingClient() + val client = client(recording, FakeSettingsDataStore()) + runCurrent() + client.setUserId("member-id") + assertThat(recording.recordedUserId).isEqualTo("member-id") + } +} From d59d7fe20a27c392bf227c940047e03712a5a4a5 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 21 Jul 2026 15:42:56 +0200 Subject: [PATCH 005/119] Add product analytics consent toggle to settings Extends SettingsUiState and SettingsEvent with analytics consent, wires observeAnalyticsConsent/setAnalyticsConsent through the presenter, and adds a Product analytics HedvigBigCard row after the email subscription block. Also updates FakeSettingsDataStore to implement the new interface methods. --- .../datastore/FakeSettingsDataStore.kt | 10 +++++++ .../profile/settings/SettingsDestination.kt | 29 +++++++++++++++++++ .../profile/settings/SettingsPresenter.kt | 13 +++++++++ .../profile/settings/SettingsPresenterTest.kt | 4 +++ 4 files changed, 56 insertions(+) diff --git a/app/data/data-settings-datastore/data-settings-datastore-test/src/main/kotlin/com/hedvig/android/data/settings/datastore/FakeSettingsDataStore.kt b/app/data/data-settings-datastore/data-settings-datastore-test/src/main/kotlin/com/hedvig/android/data/settings/datastore/FakeSettingsDataStore.kt index f01a50c480..89f7f49f0e 100644 --- a/app/data/data-settings-datastore/data-settings-datastore-test/src/main/kotlin/com/hedvig/android/data/settings/datastore/FakeSettingsDataStore.kt +++ b/app/data/data-settings-datastore/data-settings-datastore-test/src/main/kotlin/com/hedvig/android/data/settings/datastore/FakeSettingsDataStore.kt @@ -1,6 +1,7 @@ package com.hedvig.android.core.datastore import app.cash.turbine.Turbine +import com.hedvig.android.data.settings.datastore.AnalyticsConsent import com.hedvig.android.data.settings.datastore.SettingsDataStore import com.hedvig.android.theme.Theme import kotlinx.coroutines.flow.Flow @@ -9,6 +10,7 @@ import kotlinx.coroutines.flow.receiveAsFlow class FakeSettingsDataStore() : SettingsDataStore { val themeTurbine = Turbine() val subscriptionPreferenceTurbine = Turbine() + val analyticsConsentTurbine = Turbine() override suspend fun setTheme(theme: Theme) { themeTurbine.add(theme) @@ -25,4 +27,12 @@ class FakeSettingsDataStore() : SettingsDataStore { override fun observeEmailSubscriptionPreference(): Flow { return subscriptionPreferenceTurbine.asChannel().receiveAsFlow() } + + override suspend fun setAnalyticsConsent(consent: AnalyticsConsent) { + analyticsConsentTurbine.add(consent) + } + + override fun observeAnalyticsConsent(): Flow { + return analyticsConsentTurbine.asChannel().receiveAsFlow() + } } diff --git a/app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/settings/SettingsDestination.kt b/app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/settings/SettingsDestination.kt index 5afcee731d..9379e238b6 100644 --- a/app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/settings/SettingsDestination.kt +++ b/app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/settings/SettingsDestination.kt @@ -29,6 +29,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.accompanist.permissions.PermissionStatus import com.google.accompanist.permissions.PermissionStatus.Granted import com.google.accompanist.permissions.isGranted +import com.hedvig.android.data.settings.datastore.AnalyticsConsent import com.hedvig.android.design.system.hedvig.DialogDefaults.ButtonSize.BIG import com.hedvig.android.design.system.hedvig.HedvigAlertDialog import com.hedvig.android.design.system.hedvig.HedvigBigCard @@ -94,6 +95,7 @@ internal fun SettingsDestination( changeSubscriptionPreference = { viewModel.emit(SettingsEvent.ChangeSubscriptionPreference(it)) }, + onChangeAnalyticsConsent = { viewModel.emit(SettingsEvent.ChangeAnalyticsConsent(it)) }, ) } @@ -103,6 +105,7 @@ private fun SettingsScreen( notificationPermissionState: NotificationPermissionState, navigateUp: () -> Unit, changeSubscriptionPreference: (Boolean) -> Unit, + onChangeAnalyticsConsent: (AnalyticsConsent) -> Unit, openAppSettings: () -> Unit, onNotificationInfoDismissed: () -> Unit, onLanguageSelected: (Language) -> Unit, @@ -171,6 +174,30 @@ private fun SettingsScreen( enabled = true, hasError = uiState.emailSubscriptionPreferenceError, ) + Spacer(Modifier.height(4.dp)) + HedvigBigCard( + onClick = { + val newConsent = if (uiState.analyticsConsent == AnalyticsConsent.GRANTED) { + AnalyticsConsent.DENIED + } else { + AnalyticsConsent.GRANTED + } + onChangeAnalyticsConsent(newConsent) + }, + inputText = if (uiState.analyticsConsent == AnalyticsConsent.GRANTED) { + // TODO: Add "On" / "På" to Lokalise + "On" + } else { + // NOT_DECIDED renders as "Off": nothing is forwarded to Firebase in that state. + // TODO: Add "Off" / "Av" to Lokalise + "Off" + }, + // TODO: Add "Product analytics" / "Produktanalys" to Lokalise + labelText = "Product analytics", + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) Spacer(Modifier.height(16.dp)) AnimatedVisibility( @@ -382,6 +409,7 @@ fun PreviewSettingsScreen() { showNotificationReminder = true, isSubscribedToEmails = true, emailSubscriptionPreferenceError = true, + analyticsConsent = null, ), notificationPermissionState = object : NotificationPermissionState { override val showDialog = false @@ -400,6 +428,7 @@ fun PreviewSettingsScreen() { onThemeSelected = {}, onTerminateAccountClicked = {}, changeSubscriptionPreference = {}, + onChangeAnalyticsConsent = {}, ) } } diff --git a/app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/settings/SettingsPresenter.kt b/app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/settings/SettingsPresenter.kt index ba38fdd274..e372b3e214 100644 --- a/app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/settings/SettingsPresenter.kt +++ b/app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/settings/SettingsPresenter.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import com.hedvig.android.apollo.NetworkCacheManager import com.hedvig.android.apollo.auth.listeners.UploadLanguagePreferenceToBackendUseCase +import com.hedvig.android.data.settings.datastore.AnalyticsConsent import com.hedvig.android.data.settings.datastore.SettingsDataStore import com.hedvig.android.feature.profile.data.ChangeEmailSubscriptionPreferencesUseCase import com.hedvig.android.language.Language @@ -38,6 +39,8 @@ internal class SettingsPresenter( .timeToShowNotificationReminder() .collectAsState(lastState.showNotificationReminder) .value + val analyticsConsent = settingsDataStore.observeAnalyticsConsent() + .collectAsState(lastState.analyticsConsent).value CollectEvents { event -> when (event) { @@ -68,6 +71,10 @@ internal class SettingsPresenter( } } } + + is SettingsEvent.ChangeAnalyticsConsent -> { + launch { settingsDataStore.setAnalyticsConsent(event.consent) } + } } } @@ -83,6 +90,7 @@ internal class SettingsPresenter( showNotificationReminder = showNotificationReminder, isSubscribedToEmails = isSubscribedToEmails, emailSubscriptionPreferenceError = emailSubscriptionPreferenceError, + analyticsConsent = analyticsConsent, ) } } @@ -93,6 +101,7 @@ sealed interface SettingsUiState { val selectedTheme: Theme? val isSubscribedToEmails: Boolean? val showNotificationReminder: Boolean? + val analyticsConsent: AnalyticsConsent? val languageOptions: List get() = Language.entries @@ -102,6 +111,7 @@ sealed interface SettingsUiState { override val isSubscribedToEmails: Boolean? = null override val selectedTheme: Theme? = null override val showNotificationReminder: Boolean? = null + override val analyticsConsent: AnalyticsConsent? = null } data class Loaded( @@ -111,6 +121,7 @@ sealed interface SettingsUiState { override val showNotificationReminder: Boolean, override val isSubscribedToEmails: Boolean?, val emailSubscriptionPreferenceError: Boolean = false, + override val analyticsConsent: AnalyticsConsent?, ) : SettingsUiState } @@ -122,4 +133,6 @@ sealed interface SettingsEvent { data class ChangeSubscriptionPreference(val subscribe: Boolean) : SettingsEvent data object SnoozeNotificationPermissionReminder : SettingsEvent + + data class ChangeAnalyticsConsent(val consent: AnalyticsConsent) : SettingsEvent } diff --git a/app/feature/feature-profile/src/test/kotlin/com/hedvig/android/feature/profile/settings/SettingsPresenterTest.kt b/app/feature/feature-profile/src/test/kotlin/com/hedvig/android/feature/profile/settings/SettingsPresenterTest.kt index 91b8a9e971..2b0de5ba14 100644 --- a/app/feature/feature-profile/src/test/kotlin/com/hedvig/android/feature/profile/settings/SettingsPresenterTest.kt +++ b/app/feature/feature-profile/src/test/kotlin/com/hedvig/android/feature/profile/settings/SettingsPresenterTest.kt @@ -63,6 +63,7 @@ class SettingsPresenterTest { Theme.SYSTEM_DEFAULT, false, isSubscribedToEmails = false, + analyticsConsent = null, ), ) { assertThat(awaitItem().showNotificationReminder).isEqualTo(false) @@ -90,6 +91,7 @@ class SettingsPresenterTest { Theme.SYSTEM_DEFAULT, false, isSubscribedToEmails = false, + analyticsConsent = null, ), ) { assertThat(awaitItem().showNotificationReminder).isEqualTo(false) @@ -117,6 +119,7 @@ class SettingsPresenterTest { Theme.entries.first(), false, isSubscribedToEmails = false, + analyticsConsent = null, ), ) { enableNotificationsReminderManager.snoozeNotificationReminderCalls.expectNoEvents() @@ -146,6 +149,7 @@ class SettingsPresenterTest { selectedTheme = Theme.LIGHT, showNotificationReminder = false, isSubscribedToEmails = false, + analyticsConsent = null, ), ) { assertThat(awaitItem().selectedTheme).isEqualTo(Theme.LIGHT) From 8e2c0de1108d120e4bff6af38fdc3d06232ebf29 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 21 Jul 2026 15:49:16 +0200 Subject: [PATCH 006/119] Scaffold feature-onboarding module with nav keys --- .../feature-onboarding/build.gradle.kts | 41 +++++++++++++++++++ .../onboarding/navigation/OnboardingKeys.kt | 38 +++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 app/feature/feature-onboarding/build.gradle.kts create mode 100644 app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingKeys.kt diff --git a/app/feature/feature-onboarding/build.gradle.kts b/app/feature/feature-onboarding/build.gradle.kts new file mode 100644 index 0000000000..d9ec94a302 --- /dev/null +++ b/app/feature/feature-onboarding/build.gradle.kts @@ -0,0 +1,41 @@ +plugins { + id("hedvig.android.library") + id("hedvig.gradle.plugin") +} + +hedvig { + apollo("octopus") + serialization() + compose() + navKeys() + viewModels() +} + +dependencies { + implementation(libs.apollo.runtime) + implementation(libs.arrow.core) + implementation(libs.coroutines.core) + implementation(libs.androidx.datastore.core) + implementation(libs.androidx.datastore.preferencesCore) + implementation(libs.jetbrains.lifecycle.runtime.compose) + implementation(libs.kotlinx.serialization.core) + implementation(projects.apolloCore) + implementation(projects.apolloOctopusPublic) + implementation(projects.authCorePublic) + implementation(projects.coreCommonPublic) + implementation(projects.coreResources) + implementation(projects.dataSettingsDatastorePublic) + implementation(projects.designSystemHedvig) + implementation(projects.featureConnectPaymentTrustlyNavigation) + implementation(projects.featureEditCoinsuredNavigation) + implementation(projects.moleculePublic) + implementation(projects.navigationCommon) + implementation(projects.navigationCompose) + implementation(projects.theme) + + testImplementation(libs.assertK) + testImplementation(libs.coroutines.test) + testImplementation(libs.junit) + testImplementation(libs.turbine) + testImplementation(projects.moleculeTest) +} diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingKeys.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingKeys.kt new file mode 100644 index 0000000000..d3accba5f6 --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingKeys.kt @@ -0,0 +1,38 @@ +package com.hedvig.android.feature.onboarding.navigation + +import com.hedvig.android.navigation.common.HedvigNavKey +import kotlinx.serialization.Serializable + +/** + * The onboarding flow root, rendering the welcome step. Pushed on top of Home by the onboarding + * gate in `:app` for members who have not seen onboarding yet. + */ +@androidx.annotation.Keep +@Serializable +data object OnboardingKey : HedvigNavKey + +/** + * One entry per onboarding step after welcome. Deliberately tiny: the eagerly-fetched flow data + * lives in the ActivityRetainedScope OnboardingSessionStore, not in the key, so back-stack + * persistence stays cheap and process-death restore re-fetches instead of deserializing a blob. + */ +@androidx.annotation.Keep +@Serializable +internal data class OnboardingStepKey( + val stepId: OnboardingStepId, +) : HedvigNavKey + +/** + * Identifies each onboarding step past welcome. The member's concrete path (which of these apply, + * in order) is computed by buildOnboardingPath from the eagerly fetched OnboardingData. + */ +enum class OnboardingStepId { + AnalyticsConsent, + PhoneNumber, + Theme, + CoInsured, + PetIds, + InviteFriend, + ConnectPayment, + BundleDiscount, +} From 3a8c34d8740e38500e8b22e9d9af12ef6d8cd834 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 21 Jul 2026 15:56:53 +0200 Subject: [PATCH 007/119] Add onboarding query, data models and repository --- .../feature-onboarding/build.gradle.kts | 1 + .../src/main/graphql/OnboardingQuery.graphql | 41 ++++++++++ ...nboardingUpdateContactInfoMutation.graphql | 11 +++ .../feature/onboarding/data/OnboardingData.kt | 45 +++++++++++ .../onboarding/data/OnboardingRepository.kt | 76 +++++++++++++++++++ 5 files changed, 174 insertions(+) create mode 100644 app/feature/feature-onboarding/src/main/graphql/OnboardingQuery.graphql create mode 100644 app/feature/feature-onboarding/src/main/graphql/OnboardingUpdateContactInfoMutation.graphql create mode 100644 app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingData.kt create mode 100644 app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingRepository.kt diff --git a/app/feature/feature-onboarding/build.gradle.kts b/app/feature/feature-onboarding/build.gradle.kts index d9ec94a302..6b949f9b41 100644 --- a/app/feature/feature-onboarding/build.gradle.kts +++ b/app/feature/feature-onboarding/build.gradle.kts @@ -12,6 +12,7 @@ hedvig { } dependencies { + implementation(libs.apollo.normalizedCache) implementation(libs.apollo.runtime) implementation(libs.arrow.core) implementation(libs.coroutines.core) diff --git a/app/feature/feature-onboarding/src/main/graphql/OnboardingQuery.graphql b/app/feature/feature-onboarding/src/main/graphql/OnboardingQuery.graphql new file mode 100644 index 0000000000..e0e07d79ff --- /dev/null +++ b/app/feature/feature-onboarding/src/main/graphql/OnboardingQuery.graphql @@ -0,0 +1,41 @@ +query Onboarding { + currentMember { + id + email + phoneNumber + activeContracts { + id + exposureDisplayNameShort + isMissingPetId + coInsured { + hasMissingInfo + } + currentAgreement { + productVariant { + typeOfContract + displayName + } + } + } + referralInformation { + code + monthlyDiscountPerReferral { + amount + currencyCode + } + } + paymentMethods { + payinMethods { + status + } + } + crossSellV2(input: { userFlow: HOME_X_SELL, flowSource: null, experiments: [] }) { + otherCrossSells { + id + title + description + storeUrl + } + } + } +} diff --git a/app/feature/feature-onboarding/src/main/graphql/OnboardingUpdateContactInfoMutation.graphql b/app/feature/feature-onboarding/src/main/graphql/OnboardingUpdateContactInfoMutation.graphql new file mode 100644 index 0000000000..3427cc580b --- /dev/null +++ b/app/feature/feature-onboarding/src/main/graphql/OnboardingUpdateContactInfoMutation.graphql @@ -0,0 +1,11 @@ +mutation OnboardingUpdateContactInfo($email: String!, $phoneNumber: String!) { + memberUpdateContactInfo(input: { email: $email, phoneNumber: $phoneNumber }) { + userError { + message + } + member { + id + phoneNumber + } + } +} diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingData.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingData.kt new file mode 100644 index 0000000000..f1ca451c87 --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingData.kt @@ -0,0 +1,45 @@ +package com.hedvig.android.feature.onboarding.data + +/** + * Everything the onboarding flow needs, fetched eagerly in one round trip before the flow is + * shown, so the progress bar length is known up-front and no step blocks on network to render. + */ +internal data class OnboardingData( + val email: String, + val phoneNumber: String?, + val contracts: List, + val referralInformation: OnboardingReferralInformation?, + val hasConnectedPayinMethod: Boolean, + val crossSells: List, +) { + val contractsWithMissingCoInsured: List = + contracts.filter { it.missingCoInsuredCount > 0 } + + val contractsWithMissingPetId: List = + contracts.filter { it.isMissingPetId } + + val hasOnlyAccidentContracts: Boolean = + contracts.isNotEmpty() && contracts.all { it.typeOfContract.contains("ACCIDENT") } +} + +internal data class OnboardingContract( + val id: String, + val displayName: String, + val exposureName: String, + val typeOfContract: String, + val missingCoInsuredCount: Int, + val isMissingPetId: Boolean, +) + +internal data class OnboardingReferralInformation( + val code: String, + val monthlyDiscountPerReferralAmount: Double, + val currencyCode: String, +) + +internal data class OnboardingCrossSell( + val id: String, + val title: String, + val description: String, + val storeUrl: String, +) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingRepository.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingRepository.kt new file mode 100644 index 0000000000..44b75c9ca1 --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingRepository.kt @@ -0,0 +1,76 @@ +package com.hedvig.android.feature.onboarding.data + +import arrow.core.Either +import arrow.core.raise.either +import arrow.core.raise.ensure +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.cache.normalized.FetchPolicy +import com.apollographql.apollo.cache.normalized.fetchPolicy +import com.hedvig.android.apollo.ErrorMessage +import com.hedvig.android.apollo.safeExecute +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.core.common.di.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import octopus.OnboardingQuery +import octopus.OnboardingUpdateContactInfoMutation + +internal interface OnboardingRepository { + suspend fun getOnboardingData(): Either + + suspend fun updateContactInfo(email: String, phoneNumber: String): Either +} + +@ContributesBinding(AppScope::class) +@Inject +internal class OnboardingRepositoryImpl( + private val apolloClient: ApolloClient, +) : OnboardingRepository { + override suspend fun getOnboardingData(): Either = either { + val member = apolloClient + .query(OnboardingQuery()) + .fetchPolicy(FetchPolicy.NetworkOnly) + .safeExecute(::ErrorMessage) + .bind() + .currentMember + OnboardingData( + email = member.email, + phoneNumber = member.phoneNumber, + contracts = member.activeContracts.map { contract -> + OnboardingContract( + id = contract.id, + displayName = contract.currentAgreement.productVariant.displayName, + exposureName = contract.exposureDisplayNameShort, + typeOfContract = contract.currentAgreement.productVariant.typeOfContract, + missingCoInsuredCount = contract.coInsured.orEmpty().count { it.hasMissingInfo }, + isMissingPetId = contract.isMissingPetId, + ) + }, + referralInformation = member.referralInformation.let { referralInformation -> + OnboardingReferralInformation( + code = referralInformation.code, + monthlyDiscountPerReferralAmount = referralInformation.monthlyDiscountPerReferral.amount, + currencyCode = referralInformation.monthlyDiscountPerReferral.currencyCode.rawValue, + ) + }, + hasConnectedPayinMethod = member.paymentMethods.payinMethods.isNotEmpty(), + crossSells = member.crossSellV2.otherCrossSells.map { crossSell -> + OnboardingCrossSell( + id = crossSell.id, + title = crossSell.title, + description = crossSell.description, + storeUrl = crossSell.storeUrl, + ) + }, + ) + } + + override suspend fun updateContactInfo(email: String, phoneNumber: String): Either = either { + val result = apolloClient + .mutation(OnboardingUpdateContactInfoMutation(email = email, phoneNumber = phoneNumber)) + .safeExecute(::ErrorMessage) + .bind() + val userError = result.memberUpdateContactInfo.userError + ensure(userError == null) { ErrorMessage(userError?.message) } + } +} From f10f2e4295c7726f108dde5087985d289cb0b42b Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 21 Jul 2026 16:03:42 +0200 Subject: [PATCH 008/119] Add onboarding path builder with per-step skip rules --- .../feature/onboarding/data/OnboardingPath.kt | 29 +++++ .../onboarding/data/OnboardingPathTest.kt | 109 ++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingPath.kt create mode 100644 app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingPathTest.kt diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingPath.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingPath.kt new file mode 100644 index 0000000000..570f953404 --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingPath.kt @@ -0,0 +1,29 @@ +package com.hedvig.android.feature.onboarding.data + +import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId + +/** + * Computes which onboarding steps apply to this member, in presentation order. Pure so the skip + * rules stay unit-testable. Welcome is not in the result: it is the flow root itself, occupying + * progress position 0, so the progress bar renders `size + 1` segments. + */ +internal fun buildOnboardingPath(data: OnboardingData): List = buildList { + add(OnboardingStepId.AnalyticsConsent) + add(OnboardingStepId.PhoneNumber) + add(OnboardingStepId.Theme) + if (data.contractsWithMissingCoInsured.isNotEmpty()) { + add(OnboardingStepId.CoInsured) + } + if (data.contractsWithMissingPetId.isNotEmpty()) { + add(OnboardingStepId.PetIds) + } + if (data.referralInformation != null) { + add(OnboardingStepId.InviteFriend) + } + if (!data.hasConnectedPayinMethod) { + add(OnboardingStepId.ConnectPayment) + } + if (data.crossSells.isNotEmpty() && !data.hasOnlyAccidentContracts) { + add(OnboardingStepId.BundleDiscount) + } +} diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingPathTest.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingPathTest.kt new file mode 100644 index 0000000000..0cebd97459 --- /dev/null +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingPathTest.kt @@ -0,0 +1,109 @@ +package com.hedvig.android.feature.onboarding.data + +import assertk.assertThat +import assertk.assertions.contains +import assertk.assertions.containsExactly +import assertk.assertions.doesNotContain +import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId +import org.junit.Test + +class OnboardingPathTest { + private fun contract( + id: String = "contract-1", + typeOfContract: String = "SE_APARTMENT_RENT", + missingCoInsuredCount: Int = 0, + isMissingPetId: Boolean = false, + ) = OnboardingContract( + id = id, + displayName = "Home Insurance", + exposureName = "Bellmansgatan 19A", + typeOfContract = typeOfContract, + missingCoInsuredCount = missingCoInsuredCount, + isMissingPetId = isMissingPetId, + ) + + private fun data( + contracts: List = listOf(contract()), + referralInformation: OnboardingReferralInformation? = OnboardingReferralInformation("CODE", 10.0, "SEK"), + hasConnectedPayinMethod: Boolean = false, + crossSells: List = listOf(OnboardingCrossSell("cs", "Pet", "For your pet", "https://x")), + ) = OnboardingData( + email = "member@example.com", + phoneNumber = "070 990 12 32", + contracts = contracts, + referralInformation = referralInformation, + hasConnectedPayinMethod = hasConnectedPayinMethod, + crossSells = crossSells, + ) + + @Test + fun `consent, phone and theme are always present, in order, at the front`() { + val path = buildOnboardingPath(data()) + assertThat(path.take(3)).containsExactly( + OnboardingStepId.AnalyticsConsent, + OnboardingStepId.PhoneNumber, + OnboardingStepId.Theme, + ) + } + + @Test + fun `everything applicable produces the full path in canonical order`() { + val path = buildOnboardingPath( + data(contracts = listOf(contract(missingCoInsuredCount = 1, isMissingPetId = true))), + ) + assertThat(path).containsExactly( + OnboardingStepId.AnalyticsConsent, + OnboardingStepId.PhoneNumber, + OnboardingStepId.Theme, + OnboardingStepId.CoInsured, + OnboardingStepId.PetIds, + OnboardingStepId.InviteFriend, + OnboardingStepId.ConnectPayment, + OnboardingStepId.BundleDiscount, + ) + } + + @Test + fun `co-insured step is skipped when no contract has missing co-insured info`() { + val path = buildOnboardingPath(data(contracts = listOf(contract(missingCoInsuredCount = 0)))) + assertThat(path).doesNotContain(OnboardingStepId.CoInsured) + } + + @Test + fun `pet id step is skipped when no contract is missing a pet id`() { + val path = buildOnboardingPath(data(contracts = listOf(contract(isMissingPetId = false)))) + assertThat(path).doesNotContain(OnboardingStepId.PetIds) + } + + @Test + fun `invite step is skipped without referral information`() { + val path = buildOnboardingPath(data(referralInformation = null)) + assertThat(path).doesNotContain(OnboardingStepId.InviteFriend) + } + + @Test + fun `connect payment step is skipped when a payin method is already connected`() { + val path = buildOnboardingPath(data(hasConnectedPayinMethod = true)) + assertThat(path).doesNotContain(OnboardingStepId.ConnectPayment) + } + + @Test + fun `bundle step is skipped when there are no cross sells`() { + val path = buildOnboardingPath(data(crossSells = emptyList())) + assertThat(path).doesNotContain(OnboardingStepId.BundleDiscount) + } + + @Test + fun `bundle step is skipped for accident-only members even with cross sells`() { + val path = buildOnboardingPath(data(contracts = listOf(contract(typeOfContract = "SE_ACCIDENT")))) + assertThat(path).doesNotContain(OnboardingStepId.BundleDiscount) + } + + @Test + fun `bundle step is present for a mixed portfolio that includes accident`() { + val path = buildOnboardingPath( + data(contracts = listOf(contract(typeOfContract = "SE_ACCIDENT"), contract(id = "c2"))), + ) + assertThat(path).contains(OnboardingStepId.BundleDiscount) + } +} From 16e1544c032cf63f15773c6b3396148536644959 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 21 Jul 2026 16:07:28 +0200 Subject: [PATCH 009/119] Add per-member onboarding seen store DataStore-backed store that remembers per member id whether onboarding was shown. Set when the member completes or dismisses the flow, ensuring onboarding never appears again for that member on this device. --- .../onboarding/data/OnboardingSeenStore.kt | 42 +++++++++++++++++++ .../data/OnboardingSeenStoreTest.kt | 37 ++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingSeenStore.kt create mode 100644 app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingSeenStoreTest.kt diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingSeenStore.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingSeenStore.kt new file mode 100644 index 0000000000..1a03021202 --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingSeenStore.kt @@ -0,0 +1,42 @@ +package com.hedvig.android.feature.onboarding.data + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import com.hedvig.android.core.common.di.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +/** + * Remembers, per member and per install, that onboarding was shown. Set when the member completes + * the flow or dismisses it with the close button; once set, onboarding never appears again for + * that member on this device. + */ +internal interface OnboardingSeenStore { + suspend fun hasSeenOnboarding(memberId: String): Boolean + + suspend fun markOnboardingSeen(memberId: String) +} + +@ContributesBinding(AppScope::class) +@SingleIn(AppScope::class) +@Inject +internal class DataStoreOnboardingSeenStore( + private val dataStore: DataStore, +) : OnboardingSeenStore { + override suspend fun hasSeenOnboarding(memberId: String): Boolean { + return dataStore.data.map { it[seenKey(memberId)] ?: false }.first() + } + + override suspend fun markOnboardingSeen(memberId: String) { + dataStore.edit { it[seenKey(memberId)] = true } + } + + private fun seenKey(memberId: String): Preferences.Key { + return booleanPreferencesKey("com.hedvig.android.feature.onboarding.seen.$memberId") + } +} diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingSeenStoreTest.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingSeenStoreTest.kt new file mode 100644 index 0000000000..6e649a9cc2 --- /dev/null +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingSeenStoreTest.kt @@ -0,0 +1,37 @@ +package com.hedvig.android.feature.onboarding.data + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import assertk.assertThat +import assertk.assertions.isFalse +import assertk.assertions.isTrue +import java.io.File +import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toPath +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class OnboardingSeenStoreTest { + @get:Rule + val temporaryFolder: TemporaryFolder = TemporaryFolder() + + // Note: the path must NOT exist yet; DataStore treats a pre-created empty file as corrupt. + private fun store(): OnboardingSeenStore = DataStoreOnboardingSeenStore( + PreferenceDataStoreFactory.createWithPath( + produceFile = { File(temporaryFolder.root, "seen.preferences_pb").absolutePath.toPath() }, + ), + ) + + @Test + fun `a member who never saw onboarding reads false`() = runTest { + assertThat(store().hasSeenOnboarding("123")).isFalse() + } + + @Test + fun `marking seen is per member`() = runTest { + val store = store() + store.markOnboardingSeen("123") + assertThat(store.hasSeenOnboarding("123")).isTrue() + assertThat(store.hasSeenOnboarding("456")).isFalse() + } +} From 2f824dae783d000f832291a7a60e22f81723f4ef Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 21 Jul 2026 16:15:13 +0200 Subject: [PATCH 010/119] Add onboarding session store, completion use case and navigator OnboardingSessionStore caches fetched data plus the computed step path per-Activity (ActivityRetainedScope), refreshing data without reshuffling the path mid-flow. CompleteOnboardingUseCase marks the seen flag via OnboardingSeenStore for the logged-in member. OnboardingNavigator owns forward/exit movement: continueFrom pushes the next step key, falls back to exitOnboarding when the session is missing or the last step is done. Shared FakeOnboardingRepository and testOnboardingData test fixture added for later presenter tests. --- .../feature-onboarding/build.gradle.kts | 1 + .../data/CompleteOnboardingUseCase.kt | 29 ++++ .../onboarding/data/OnboardingSessionStore.kt | 53 ++++++++ .../navigation/OnboardingNavigator.kt | 56 ++++++++ .../onboarding/FakeOnboardingRepository.kt | 47 +++++++ .../navigation/OnboardingNavigatorTest.kt | 124 ++++++++++++++++++ 6 files changed, 310 insertions(+) create mode 100644 app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/CompleteOnboardingUseCase.kt create mode 100644 app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingSessionStore.kt create mode 100644 app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingNavigator.kt create mode 100644 app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/FakeOnboardingRepository.kt create mode 100644 app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingNavigatorTest.kt diff --git a/app/feature/feature-onboarding/build.gradle.kts b/app/feature/feature-onboarding/build.gradle.kts index 6b949f9b41..9f6127216c 100644 --- a/app/feature/feature-onboarding/build.gradle.kts +++ b/app/feature/feature-onboarding/build.gradle.kts @@ -38,5 +38,6 @@ dependencies { testImplementation(libs.coroutines.test) testImplementation(libs.junit) testImplementation(libs.turbine) + testImplementation(projects.loggingTest) testImplementation(projects.moleculeTest) } diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/CompleteOnboardingUseCase.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/CompleteOnboardingUseCase.kt new file mode 100644 index 0000000000..8086e1a5f2 --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/CompleteOnboardingUseCase.kt @@ -0,0 +1,29 @@ +package com.hedvig.android.feature.onboarding.data + +import com.hedvig.android.auth.MemberIdService +import com.hedvig.android.core.common.di.AppScope +import com.hedvig.android.logger.LogPriority +import com.hedvig.android.logger.logcat +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import kotlinx.coroutines.flow.first + +internal interface CompleteOnboardingUseCase { + suspend fun invoke() +} + +@ContributesBinding(AppScope::class) +@Inject +internal class CompleteOnboardingUseCaseImpl( + private val memberIdService: MemberIdService, + private val onboardingSeenStore: OnboardingSeenStore, +) : CompleteOnboardingUseCase { + override suspend fun invoke() { + val memberId = memberIdService.getMemberId().first() + if (memberId == null) { + logcat(LogPriority.WARN) { "Completing onboarding without a member id; seen flag not stored" } + return + } + onboardingSeenStore.markOnboardingSeen(memberId) + } +} diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingSessionStore.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingSessionStore.kt new file mode 100644 index 0000000000..46a39dc5c0 --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingSessionStore.kt @@ -0,0 +1,53 @@ +package com.hedvig.android.feature.onboarding.data + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.core.common.di.ActivityRetainedScope +import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Holds the eagerly fetched onboarding data plus the computed step path for the lifetime of one + * Activity. Keys stay tiny (see OnboardingStepKey); after process death this cache is empty and + * the visible step re-fetches through [getOrFetchSession]. + */ +@SingleIn(ActivityRetainedScope::class) +@Inject +internal class OnboardingSessionStore( + private val onboardingRepository: OnboardingRepository, +) { + private val mutex = Mutex() + private var cachedSession: OnboardingSession? = null + + val currentSession: OnboardingSession? + get() = cachedSession + + suspend fun getOrFetchSession(): Either = mutex.withLock { + cachedSession?.let { return@withLock it.right() } + onboardingRepository.getOnboardingData().map { data -> + OnboardingSession(data = data, path = buildOnboardingPath(data)).also { cachedSession = it } + } + } + + /** + * Refetches the data (used after returning from an external flow like edit co-insured or + * Trustly) but keeps the original path: the progress bar must not reshuffle mid-flow. + */ + suspend fun refreshData(): Either = mutex.withLock { + val existing = cachedSession + ?: return@withLock ErrorMessage("No onboarding session to refresh").left() + onboardingRepository.getOnboardingData().map { data -> + existing.copy(data = data).also { cachedSession = it } + } + } +} + +internal data class OnboardingSession( + val data: OnboardingData, + val path: List, +) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingNavigator.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingNavigator.kt new file mode 100644 index 0000000000..015fdd132a --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingNavigator.kt @@ -0,0 +1,56 @@ +package com.hedvig.android.feature.onboarding.navigation + +import com.hedvig.android.core.common.di.ActivityRetainedScope +import com.hedvig.android.feature.onboarding.data.CompleteOnboardingUseCase +import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore +import com.hedvig.android.logger.logcat +import com.hedvig.android.navigation.compose.Backstack +import com.hedvig.android.navigation.compose.add +import com.hedvig.android.navigation.compose.removeAllOf +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn + +/** + * Owns forward/exit movement through the onboarding flow. Steps are pushed as real back stack + * entries so predictive back and the up arrow behave natively; exiting removes every onboarding + * key, landing on whatever was underneath (Home). + */ +@SingleIn(ActivityRetainedScope::class) +@Inject +internal class OnboardingNavigator( + private val backstack: Backstack, + private val sessionStore: OnboardingSessionStore, + private val completeOnboardingUseCase: CompleteOnboardingUseCase, +) { + /** [current] is null when continuing from the welcome screen (OnboardingKey). */ + suspend fun continueFrom(current: OnboardingStepId?) { + val session = sessionStore.currentSession + if (session == null) { + logcat { "Onboarding continue without a session, exiting flow" } + exitOnboarding() + return + } + val next = if (current == null) { + session.path.firstOrNull() + } else { + val currentIndex = session.path.indexOf(current) + if (currentIndex == -1) null else session.path.getOrNull(currentIndex + 1) + } + if (next == null) { + exitOnboarding() + } else { + backstack.add(OnboardingStepKey(next)) + } + } + + suspend fun exitOnboarding() { + completeOnboardingUseCase.invoke() + backstack.removeAllOf() + backstack.removeAllOf() + } + + /** Reserved for the top app bar back arrow only, per the navigation rule. */ + fun navigateBack() { + backstack.navigateUp() + } +} diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/FakeOnboardingRepository.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/FakeOnboardingRepository.kt new file mode 100644 index 0000000000..f30e8f8002 --- /dev/null +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/FakeOnboardingRepository.kt @@ -0,0 +1,47 @@ +package com.hedvig.android.feature.onboarding + +import app.cash.turbine.Turbine +import arrow.core.Either +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.feature.onboarding.data.OnboardingContract +import com.hedvig.android.feature.onboarding.data.OnboardingCrossSell +import com.hedvig.android.feature.onboarding.data.OnboardingData +import com.hedvig.android.feature.onboarding.data.OnboardingReferralInformation +import com.hedvig.android.feature.onboarding.data.OnboardingRepository + +internal class FakeOnboardingRepository : OnboardingRepository { + val onboardingDataResponses = Turbine>() + val updateContactInfoResponses = Turbine>() + + override suspend fun getOnboardingData(): Either { + return onboardingDataResponses.awaitItem() + } + + override suspend fun updateContactInfo(email: String, phoneNumber: String): Either { + return updateContactInfoResponses.awaitItem() + } +} + +internal fun testOnboardingData( + phoneNumber: String? = "070 990 12 32", + contracts: List = listOf( + OnboardingContract( + id = "contract-1", + displayName = "Home Insurance", + exposureName = "Bellmansgatan 19A", + typeOfContract = "SE_APARTMENT_RENT", + missingCoInsuredCount = 1, + isMissingPetId = false, + ), + ), + referralInformation: OnboardingReferralInformation? = OnboardingReferralInformation("CODE", 10.0, "SEK"), + hasConnectedPayinMethod: Boolean = false, + crossSells: List = listOf(OnboardingCrossSell("cs", "Pet", "For your pet", "https://x")), +): OnboardingData = OnboardingData( + email = "member@example.com", + phoneNumber = phoneNumber, + contracts = contracts, + referralInformation = referralInformation, + hasConnectedPayinMethod = hasConnectedPayinMethod, + crossSells = crossSells, +) diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingNavigatorTest.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingNavigatorTest.kt new file mode 100644 index 0000000000..e52912588f --- /dev/null +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingNavigatorTest.kt @@ -0,0 +1,124 @@ +package com.hedvig.android.feature.onboarding.navigation + +import androidx.compose.runtime.mutableStateListOf +import arrow.core.right +import assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isTrue +import com.hedvig.android.feature.onboarding.FakeOnboardingRepository +import com.hedvig.android.feature.onboarding.data.CompleteOnboardingUseCase +import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore +import com.hedvig.android.feature.onboarding.testOnboardingData +import com.hedvig.android.logger.TestLogcatLoggingRule +import com.hedvig.android.navigation.common.HedvigNavKey +import com.hedvig.android.navigation.compose.Backstack +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +internal class OnboardingNavigatorTest { + @get:Rule + val testLogcatLogger = TestLogcatLoggingRule() + + private class TestBackstack : Backstack { + override val entries: MutableList = mutableStateListOf() + } + + private class FakeCompleteOnboardingUseCase : CompleteOnboardingUseCase { + var invoked: Boolean = false + + override suspend fun invoke() { + invoked = true + } + } + + private suspend fun sessionStoreWithData(repository: FakeOnboardingRepository): OnboardingSessionStore { + val store = OnboardingSessionStore(repository) + repository.onboardingDataResponses.add(testOnboardingData().right()) + store.getOrFetchSession() + return store + } + + @Test + fun `continue from welcome pushes the first step of the path`() = runTest { + val backstack = TestBackstack().apply { entries.add(OnboardingKey) } + val repository = FakeOnboardingRepository() + val navigator = OnboardingNavigator(backstack, sessionStoreWithData(repository), FakeCompleteOnboardingUseCase()) + + navigator.continueFrom(null) + + assertThat(backstack.entries).containsExactly( + OnboardingKey, + OnboardingStepKey(OnboardingStepId.AnalyticsConsent), + ) + } + + @Test + fun `continue from a mid step pushes the next step`() = runTest { + val backstack = TestBackstack().apply { + entries.add(OnboardingKey) + entries.add(OnboardingStepKey(OnboardingStepId.AnalyticsConsent)) + } + val repository = FakeOnboardingRepository() + val navigator = OnboardingNavigator(backstack, sessionStoreWithData(repository), FakeCompleteOnboardingUseCase()) + + navigator.continueFrom(OnboardingStepId.AnalyticsConsent) + + assertThat(backstack.entries.last()).isEqualTo(OnboardingStepKey(OnboardingStepId.PhoneNumber)) + } + + @Test + fun `continue from the last step marks seen and removes all onboarding keys`() = runTest { + val backstack = TestBackstack().apply { + entries.add(OnboardingKey) + entries.add(OnboardingStepKey(OnboardingStepId.BundleDiscount)) + } + val repository = FakeOnboardingRepository() + val completeOnboarding = FakeCompleteOnboardingUseCase() + val navigator = OnboardingNavigator(backstack, sessionStoreWithData(repository), completeOnboarding) + + navigator.continueFrom(OnboardingStepId.BundleDiscount) + + assertThat(completeOnboarding.invoked).isTrue() + assertThat(backstack.entries).isEmpty() + } + + @Test + fun `exit marks seen and removes all onboarding keys, leaving surrounding entries alone`() = runTest { + val backstack = TestBackstack().apply { + entries.add(NonOnboardingKey) + entries.add(OnboardingKey) + entries.add(OnboardingStepKey(OnboardingStepId.AnalyticsConsent)) + } + val repository = FakeOnboardingRepository() + val completeOnboarding = FakeCompleteOnboardingUseCase() + val navigator = OnboardingNavigator(backstack, sessionStoreWithData(repository), completeOnboarding) + + navigator.exitOnboarding() + + assertThat(completeOnboarding.invoked).isTrue() + assertThat(backstack.entries).containsExactly(NonOnboardingKey) + } + + @Test + fun `continue without a session exits the flow without marking crash-level state`() = runTest { + val backstack = TestBackstack().apply { entries.add(OnboardingKey) } + val completeOnboarding = FakeCompleteOnboardingUseCase() + val navigator = OnboardingNavigator( + backstack, + OnboardingSessionStore(FakeOnboardingRepository()), + completeOnboarding, + ) + + navigator.continueFrom(null) + + assertThat(backstack.entries).isEmpty() + assertThat(completeOnboarding.invoked).isTrue() + } +} + +@kotlinx.serialization.Serializable +internal data object NonOnboardingKey : HedvigNavKey From 7e5801ac478b7c64ed0c380009cfd3360e1f29c5 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 21 Jul 2026 16:20:29 +0200 Subject: [PATCH 011/119] Avoid registering test-only nav key in the serializers module Remove @Serializable from NonOnboardingKey in OnboardingNavigatorTest so the navigation-keys KSP processor no longer picks it up and generates a @ContributesTo(AppScope::class) SerializersModule contributor for a test-only sentinel type. Tightened visibility to private since no generated cross-file reference exists without the annotation. --- .../feature/onboarding/navigation/OnboardingNavigatorTest.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingNavigatorTest.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingNavigatorTest.kt index e52912588f..927b5d539a 100644 --- a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingNavigatorTest.kt +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingNavigatorTest.kt @@ -120,5 +120,4 @@ internal class OnboardingNavigatorTest { } } -@kotlinx.serialization.Serializable -internal data object NonOnboardingKey : HedvigNavKey +private data object NonOnboardingKey : HedvigNavKey From 4e4845c01ed09b9a694c20018d5c54a50e5d2e4f Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 21 Jul 2026 16:25:29 +0200 Subject: [PATCH 012/119] Add onboarding step scaffold with segmented progress bar --- .../onboarding/ui/OnboardingStepScaffold.kt | 234 ++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt new file mode 100644 index 0000000000..5c7cc53d66 --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt @@ -0,0 +1,234 @@ +package com.hedvig.android.feature.onboarding.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import com.hedvig.android.design.system.hedvig.HedvigButton +import com.hedvig.android.design.system.hedvig.HedvigPreview +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTextButton +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.Icon +import com.hedvig.android.design.system.hedvig.Surface +import com.hedvig.android.design.system.hedvig.icon.ArrowLeft +import com.hedvig.android.design.system.hedvig.icon.Close +import com.hedvig.android.design.system.hedvig.icon.HedvigIcons +import com.hedvig.android.feature.onboarding.data.OnboardingSession +import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId + +internal data class OnboardingProgress( + val totalSteps: Int, + val currentIndex: Int, +) + +/** + * Welcome (stepId == null) sits at progress position 0; path steps follow it. If [stepId] is not + * in the path (possible only when a process-death refetch rebuilt a different path under a + * restored step), this degrades to index 0 rather than crashing; the step stays fully usable. + */ +internal fun OnboardingSession.progressFor(stepId: OnboardingStepId?): OnboardingProgress { + return OnboardingProgress( + totalSteps = path.size + 1, + currentIndex = if (stepId == null) 0 else path.indexOf(stepId) + 1, + ) +} + +@Composable +internal fun OnboardingStepScaffold( + progress: OnboardingProgress?, + showBackButton: Boolean, + onBackClick: () -> Unit, + onCloseClick: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + Surface( + color = HedvigTheme.colorScheme.backgroundPrimary, + modifier = modifier.fillMaxSize(), + ) { + Column( + Modifier.windowInsetsPadding( + WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top), + ), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .padding(horizontal = 16.dp), + ) { + if (showBackButton) { + Icon( + imageVector = HedvigIcons.ArrowLeft, + // TODO: Add "Go back" / "Gå tillbaka" to Lokalise (or reuse an existing a11y string) + contentDescription = "Go back", + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .clickable(onClick = onBackClick), + ) + } else { + Spacer(Modifier.size(24.dp)) + } + if (progress != null) { + OnboardingProgressBar( + progress = progress, + modifier = Modifier + .weight(1f) + .padding(horizontal = 24.dp), + ) + } else { + Spacer(Modifier.weight(1f)) + } + Icon( + imageVector = HedvigIcons.Close, + // TODO: Add "Close" / "Stäng" to Lokalise (or reuse an existing a11y string) + contentDescription = "Close", + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .clickable(onClick = onCloseClick), + ) + } + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()), + content = content, + ) + } + } +} + +@Composable +private fun OnboardingProgressBar(progress: OnboardingProgress, modifier: Modifier = Modifier) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier) { + repeat(progress.totalSteps) { index -> + val isActivated = index <= progress.currentIndex + Box( + Modifier + .weight(1f) + .height(2.dp) + .clip(CircleShape) + .background( + if (isActivated) { + HedvigTheme.colorScheme.fillPrimary + } else { + HedvigTheme.colorScheme.surfaceSecondary + }, + ), + ) + } + } +} + +@Composable +internal fun OnboardingStepHeader(title: String, description: String, modifier: Modifier = Modifier) { + Column(modifier.padding(horizontal = 16.dp)) { + HedvigText(text = title) + HedvigText(text = description, color = HedvigTheme.colorScheme.textSecondary) + } +} + +/** Bottom-anchored button area: primary on top, optional ghost secondary below, matching Figma. */ +@Composable +internal fun ColumnScope.OnboardingStepButtons( + primaryText: String, + onPrimaryClick: () -> Unit, + primaryEnabled: Boolean = true, + secondaryText: String? = null, + onSecondaryClick: (() -> Unit)? = null, +) { + Spacer(Modifier.weight(1f)) + Spacer(Modifier.height(16.dp)) + HedvigButton( + text = primaryText, + onClick = onPrimaryClick, + enabled = primaryEnabled, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + if (secondaryText != null && onSecondaryClick != null) { + Spacer(Modifier.height(8.dp)) + HedvigTextButton( + text = secondaryText, + onClick = onSecondaryClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + Spacer(Modifier.height(16.dp)) + Spacer(Modifier.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom))) +} + +@HedvigPreview +@Composable +private fun PreviewOnboardingStepScaffold() { + HedvigTheme { + OnboardingStepScaffold( + progress = OnboardingProgress(totalSteps = 5, currentIndex = 2), + showBackButton = true, + onBackClick = {}, + onCloseClick = {}, + ) { + OnboardingStepHeader( + title = "Step title", + description = "Step description with some helpful text.", + ) + OnboardingStepButtons( + primaryText = "Continue", + onPrimaryClick = {}, + secondaryText = "Skip", + onSecondaryClick = {}, + ) + } + } +} + +@HedvigPreview +@Composable +private fun PreviewOnboardingStepScaffoldNoProgress() { + HedvigTheme { + OnboardingStepScaffold( + progress = null, + showBackButton = false, + onBackClick = {}, + onCloseClick = {}, + ) { + OnboardingStepHeader( + title = "Welcome", + description = "Welcome to Hedvig onboarding.", + ) + OnboardingStepButtons( + primaryText = "Get started", + onPrimaryClick = {}, + ) + } + } +} From 1dc09b14e148a996010463b68013ef969c70aa98 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 21 Jul 2026 16:32:44 +0200 Subject: [PATCH 013/119] Use IconButton for onboarding scaffold back and close actions --- .../onboarding/ui/OnboardingStepScaffold.kt | 42 ++++++++++--------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt index 5c7cc53d66..fe6ddb5d73 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt @@ -1,7 +1,6 @@ package com.hedvig.android.feature.onboarding.ui import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -32,6 +31,7 @@ import com.hedvig.android.design.system.hedvig.HedvigText import com.hedvig.android.design.system.hedvig.HedvigTextButton import com.hedvig.android.design.system.hedvig.HedvigTheme import com.hedvig.android.design.system.hedvig.Icon +import com.hedvig.android.design.system.hedvig.IconButton import com.hedvig.android.design.system.hedvig.Surface import com.hedvig.android.design.system.hedvig.icon.ArrowLeft import com.hedvig.android.design.system.hedvig.icon.Close @@ -82,17 +82,18 @@ internal fun OnboardingStepScaffold( .padding(horizontal = 16.dp), ) { if (showBackButton) { - Icon( - imageVector = HedvigIcons.ArrowLeft, - // TODO: Add "Go back" / "Gå tillbaka" to Lokalise (or reuse an existing a11y string) - contentDescription = "Go back", - modifier = Modifier - .size(24.dp) - .clip(CircleShape) - .clickable(onClick = onBackClick), - ) + IconButton( + onClick = onBackClick, + ) { + Icon( + imageVector = HedvigIcons.ArrowLeft, + // TODO: Add "Go back" / "Gå tillbaka" to Lokalise (or reuse an existing a11y string) + contentDescription = "Go back", + modifier = Modifier.size(24.dp), + ) + } } else { - Spacer(Modifier.size(24.dp)) + Spacer(Modifier.size(40.dp)) } if (progress != null) { OnboardingProgressBar( @@ -104,15 +105,16 @@ internal fun OnboardingStepScaffold( } else { Spacer(Modifier.weight(1f)) } - Icon( - imageVector = HedvigIcons.Close, - // TODO: Add "Close" / "Stäng" to Lokalise (or reuse an existing a11y string) - contentDescription = "Close", - modifier = Modifier - .size(24.dp) - .clip(CircleShape) - .clickable(onClick = onCloseClick), - ) + IconButton( + onClick = onCloseClick, + ) { + Icon( + imageVector = HedvigIcons.Close, + // TODO: Add "Close" / "Stäng" to Lokalise (or reuse an existing a11y string) + contentDescription = "Close", + modifier = Modifier.size(24.dp), + ) + } } Column( modifier = Modifier From 85bd9cbe96d1636fd11de190a1f2a301812147fd Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 21 Jul 2026 16:41:32 +0200 Subject: [PATCH 014/119] Add onboarding welcome step Implements the welcome screen (Task 10): OnboardingWelcomeViewModel, OnboardingWelcomePresenter, OnboardingWelcomeUiState/Event, and OnboardingWelcomeDestination. Adds testOptions.unitTests.isReturnDefaultValues so molecule presenter tests can run without Robolectric. --- .../feature-onboarding/build.gradle.kts | 4 + .../welcome/OnboardingWelcomeDestination.kt | 147 ++++++++++++++++++ .../welcome/OnboardingWelcomePresenterTest.kt | 92 +++++++++++ 3 files changed, 243 insertions(+) create mode 100644 app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt create mode 100644 app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomePresenterTest.kt diff --git a/app/feature/feature-onboarding/build.gradle.kts b/app/feature/feature-onboarding/build.gradle.kts index 9f6127216c..18684a8e16 100644 --- a/app/feature/feature-onboarding/build.gradle.kts +++ b/app/feature/feature-onboarding/build.gradle.kts @@ -11,6 +11,10 @@ hedvig { viewModels() } +android { + testOptions.unitTests.isReturnDefaultValues = true +} + dependencies { implementation(libs.apollo.normalizedCache) implementation(libs.apollo.runtime) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt new file mode 100644 index 0000000000..713aea7ff1 --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt @@ -0,0 +1,147 @@ +package com.hedvig.android.feature.onboarding.ui.welcome + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.hedvig.android.core.common.di.ActivityRetainedScope +import com.hedvig.android.core.common.di.HedvigViewModel +import com.hedvig.android.design.system.hedvig.HedvigErrorSection +import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgressDebounced +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore +import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator +import com.hedvig.android.feature.onboarding.ui.OnboardingProgress +import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons +import com.hedvig.android.feature.onboarding.ui.OnboardingStepScaffold +import com.hedvig.android.feature.onboarding.ui.progressFor +import com.hedvig.android.molecule.public.MoleculePresenter +import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.molecule.public.MoleculeViewModel +import dev.zacsweers.metro.Inject +import kotlinx.coroutines.launch + +@Inject +@HedvigViewModel(ActivityRetainedScope::class) +internal class OnboardingWelcomeViewModel( + sessionStore: OnboardingSessionStore, + navigator: OnboardingNavigator, +) : MoleculeViewModel( + initialState = OnboardingWelcomeUiState.Loading, + presenter = OnboardingWelcomePresenter(sessionStore, navigator), + ) + +internal class OnboardingWelcomePresenter( + private val sessionStore: OnboardingSessionStore, + private val navigator: OnboardingNavigator, +) : MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present( + lastState: OnboardingWelcomeUiState, + ): OnboardingWelcomeUiState { + var currentState by remember { mutableStateOf(lastState) } + var loadIteration by remember { mutableIntStateOf(0) } + + LaunchedEffect(loadIteration) { + if (currentState is OnboardingWelcomeUiState.Content) return@LaunchedEffect + currentState = OnboardingWelcomeUiState.Loading + sessionStore.getOrFetchSession().fold( + ifLeft = { currentState = OnboardingWelcomeUiState.Error }, + ifRight = { session -> + currentState = OnboardingWelcomeUiState.Content(session.progressFor(null)) + }, + ) + } + + CollectEvents { event -> + when (event) { + OnboardingWelcomeEvent.Retry -> loadIteration++ + OnboardingWelcomeEvent.GetStarted -> launch { navigator.continueFrom(null) } + OnboardingWelcomeEvent.Close -> launch { navigator.exitOnboarding() } + } + } + + return currentState + } +} + +internal sealed interface OnboardingWelcomeUiState { + data object Loading : OnboardingWelcomeUiState + + data object Error : OnboardingWelcomeUiState + + data class Content(val progress: OnboardingProgress) : OnboardingWelcomeUiState +} + +internal sealed interface OnboardingWelcomeEvent { + data object Retry : OnboardingWelcomeEvent + + data object GetStarted : OnboardingWelcomeEvent + + data object Close : OnboardingWelcomeEvent +} + +@Composable +internal fun OnboardingWelcomeDestination(viewModel: OnboardingWelcomeViewModel) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + OnboardingStepScaffold( + progress = (uiState as? OnboardingWelcomeUiState.Content)?.progress, + showBackButton = false, + onBackClick = {}, + onCloseClick = { viewModel.emit(OnboardingWelcomeEvent.Close) }, + ) { + when (uiState) { + OnboardingWelcomeUiState.Loading -> { + HedvigFullScreenCenterAlignedProgressDebounced() + } + + OnboardingWelcomeUiState.Error -> { + HedvigErrorSection( + onButtonClick = { viewModel.emit(OnboardingWelcomeEvent.Retry) }, + ) + } + + is OnboardingWelcomeUiState.Content -> { + Spacer(Modifier.weight(1f)) + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) { + // Static stand-in for the Figma app-icon-with-badge graphic; animation is a follow-up. + Box( + Modifier + .size(96.dp) + .background(HedvigTheme.colorScheme.fillPrimary, HedvigTheme.shapes.cornerLarge), + ) + Spacer(Modifier.height(24.dp)) + // TODO: Add "Welcome to Hedvig" / "Välkommen till Hedvig" to Lokalise + HedvigText("Welcome to Hedvig") + // TODO: Add "Follow the steps to get started with your new insurance" / + // "Följ stegen för att komma igång med din nya försäkring" to Lokalise + HedvigText( + text = "Follow the steps to get started with your new insurance", + color = HedvigTheme.colorScheme.textSecondary, + ) + } + // TODO: Add "Get started" / "Kom igång" to Lokalise + OnboardingStepButtons( + primaryText = "Get started", + onPrimaryClick = { viewModel.emit(OnboardingWelcomeEvent.GetStarted) }, + ) + } + } + } +} diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomePresenterTest.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomePresenterTest.kt new file mode 100644 index 0000000000..2f209d3f01 --- /dev/null +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomePresenterTest.kt @@ -0,0 +1,92 @@ +package com.hedvig.android.feature.onboarding.ui.welcome + +import androidx.compose.runtime.mutableStateListOf +import arrow.core.left +import arrow.core.right +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.feature.onboarding.FakeOnboardingRepository +import com.hedvig.android.feature.onboarding.data.CompleteOnboardingUseCase +import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore +import com.hedvig.android.feature.onboarding.navigation.OnboardingKey +import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator +import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId +import com.hedvig.android.feature.onboarding.navigation.OnboardingStepKey +import com.hedvig.android.feature.onboarding.testOnboardingData +import com.hedvig.android.logger.TestLogcatLoggingRule +import com.hedvig.android.molecule.test.test +import com.hedvig.android.navigation.common.HedvigNavKey +import com.hedvig.android.navigation.compose.Backstack +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +internal class OnboardingWelcomePresenterTest { + @get:Rule + val testLogcatLogger = TestLogcatLoggingRule() + + private class TestBackstack : Backstack { + override val entries: MutableList = mutableStateListOf() + } + + private class NoopCompleteOnboardingUseCase : CompleteOnboardingUseCase { + override suspend fun invoke() {} + } + + @Test + fun `session fetch success shows content with the progress of the whole path`() = runTest { + val repository = FakeOnboardingRepository() + val sessionStore = OnboardingSessionStore(repository) + val navigator = OnboardingNavigator(TestBackstack(), sessionStore, NoopCompleteOnboardingUseCase()) + val presenter = OnboardingWelcomePresenter(sessionStore, navigator) + + presenter.test(OnboardingWelcomeUiState.Loading) { + skipItems(1) + repository.onboardingDataResponses.add(testOnboardingData().right()) + val content = awaitItem() + assertThat(content).isInstanceOf() + // default test data: consent+phone+theme+coinsured+invite+payment+bundle = 7 steps + welcome + assertThat((content as OnboardingWelcomeUiState.Content).progress.totalSteps).isEqualTo(8) + assertThat(content.progress.currentIndex).isEqualTo(0) + } + } + + @Test + fun `session fetch failure shows error, retry refetches`() = runTest { + val repository = FakeOnboardingRepository() + val sessionStore = OnboardingSessionStore(repository) + val navigator = OnboardingNavigator(TestBackstack(), sessionStore, NoopCompleteOnboardingUseCase()) + val presenter = OnboardingWelcomePresenter(sessionStore, navigator) + + presenter.test(OnboardingWelcomeUiState.Loading) { + skipItems(1) + repository.onboardingDataResponses.add(ErrorMessage("boom").left()) + assertThat(awaitItem()).isInstanceOf() + sendEvent(OnboardingWelcomeEvent.Retry) + skipItems(1) // back to Loading + repository.onboardingDataResponses.add(testOnboardingData().right()) + assertThat(awaitItem()).isInstanceOf() + } + } + + @Test + fun `get started pushes the first path step onto the backstack`() = runTest { + val backstack = TestBackstack().apply { entries.add(OnboardingKey) } + val repository = FakeOnboardingRepository() + val sessionStore = OnboardingSessionStore(repository) + val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) + val presenter = OnboardingWelcomePresenter(sessionStore, navigator) + + presenter.test(OnboardingWelcomeUiState.Loading) { + skipItems(1) + repository.onboardingDataResponses.add(testOnboardingData().right()) + awaitItem() + sendEvent(OnboardingWelcomeEvent.GetStarted) + runCurrent() + assertThat(backstack.entries.last()).isEqualTo(OnboardingStepKey(OnboardingStepId.AnalyticsConsent)) + } + } +} From 0c5a55a92461bc3ce2f5e900d8b088db35941607 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 21 Jul 2026 16:45:49 +0200 Subject: [PATCH 015/119] Remove unused import in onboarding welcome destination --- .../onboarding/ui/welcome/OnboardingWelcomeDestination.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt index 713aea7ff1..960ce660d0 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect From c8a732c9c96d9115f096bc0d9da09dc02cf6e7f6 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 21 Jul 2026 16:49:57 +0200 Subject: [PATCH 016/119] Add onboarding analytics consent step --- .../consent/OnboardingConsentDestination.kt | 163 ++++++++++++++++++ .../onboarding/FakeSettingsDataStore.kt | 25 +++ .../consent/OnboardingConsentPresenterTest.kt | 77 +++++++++ 3 files changed, 265 insertions(+) create mode 100644 app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingConsentDestination.kt create mode 100644 app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/FakeSettingsDataStore.kt create mode 100644 app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingConsentPresenterTest.kt diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingConsentDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingConsentDestination.kt new file mode 100644 index 0000000000..1b4d1e2c35 --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingConsentDestination.kt @@ -0,0 +1,163 @@ +package com.hedvig.android.feature.onboarding.ui.consent + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.hedvig.android.core.common.di.ActivityRetainedScope +import com.hedvig.android.core.common.di.HedvigViewModel +import com.hedvig.android.data.settings.datastore.AnalyticsConsent +import com.hedvig.android.data.settings.datastore.SettingsDataStore +import com.hedvig.android.design.system.hedvig.HedvigErrorSection +import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgressDebounced +import com.hedvig.android.design.system.hedvig.HedvigTextButton +import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore +import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator +import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId +import com.hedvig.android.feature.onboarding.ui.OnboardingProgress +import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons +import com.hedvig.android.feature.onboarding.ui.OnboardingStepHeader +import com.hedvig.android.feature.onboarding.ui.OnboardingStepScaffold +import com.hedvig.android.feature.onboarding.ui.progressFor +import com.hedvig.android.molecule.public.MoleculePresenter +import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.molecule.public.MoleculeViewModel +import dev.zacsweers.metro.Inject +import kotlinx.coroutines.launch + +@Inject +@HedvigViewModel(ActivityRetainedScope::class) +internal class OnboardingConsentViewModel( + sessionStore: OnboardingSessionStore, + navigator: OnboardingNavigator, + settingsDataStore: SettingsDataStore, +) : MoleculeViewModel( + initialState = OnboardingConsentUiState.Loading, + presenter = OnboardingConsentPresenter(sessionStore, navigator, settingsDataStore), + ) + +internal class OnboardingConsentPresenter( + private val sessionStore: OnboardingSessionStore, + private val navigator: OnboardingNavigator, + private val settingsDataStore: SettingsDataStore, +) : MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present( + lastState: OnboardingConsentUiState, + ): OnboardingConsentUiState { + var currentState by remember { mutableStateOf(lastState) } + var loadIteration by remember { mutableIntStateOf(0) } + + LaunchedEffect(loadIteration) { + if (currentState is OnboardingConsentUiState.Content) return@LaunchedEffect + currentState = OnboardingConsentUiState.Loading + sessionStore.getOrFetchSession().fold( + ifLeft = { currentState = OnboardingConsentUiState.Error }, + ifRight = { session -> + currentState = OnboardingConsentUiState.Content(session.progressFor(OnboardingStepId.AnalyticsConsent)) + }, + ) + } + + CollectEvents { event -> + when (event) { + OnboardingConsentEvent.Retry -> loadIteration++ + + OnboardingConsentEvent.Close -> launch { navigator.exitOnboarding() } + + OnboardingConsentEvent.Allow -> launch { + settingsDataStore.setAnalyticsConsent(AnalyticsConsent.GRANTED) + navigator.continueFrom(OnboardingStepId.AnalyticsConsent) + } + + OnboardingConsentEvent.Deny -> launch { + settingsDataStore.setAnalyticsConsent(AnalyticsConsent.DENIED) + navigator.continueFrom(OnboardingStepId.AnalyticsConsent) + } + } + } + + return currentState + } +} + +internal sealed interface OnboardingConsentUiState { + data object Loading : OnboardingConsentUiState + + data object Error : OnboardingConsentUiState + + data class Content(val progress: OnboardingProgress) : OnboardingConsentUiState +} + +internal sealed interface OnboardingConsentEvent { + data object Retry : OnboardingConsentEvent + + data object Close : OnboardingConsentEvent + + data object Allow : OnboardingConsentEvent + + data object Deny : OnboardingConsentEvent +} + +@Composable +internal fun OnboardingConsentDestination( + viewModel: OnboardingConsentViewModel, + navigateUp: () -> Unit, + openPrivacyPolicy: () -> Unit, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + OnboardingStepScaffold( + progress = (uiState as? OnboardingConsentUiState.Content)?.progress, + showBackButton = true, + onBackClick = navigateUp, + onCloseClick = { viewModel.emit(OnboardingConsentEvent.Close) }, + ) { + when (uiState) { + OnboardingConsentUiState.Loading -> { + HedvigFullScreenCenterAlignedProgressDebounced() + } + + OnboardingConsentUiState.Error -> { + HedvigErrorSection( + onButtonClick = { viewModel.emit(OnboardingConsentEvent.Retry) }, + ) + } + + is OnboardingConsentUiState.Content -> { + Spacer(Modifier.height(8.dp)) + OnboardingStepHeader( + // TODO: Add "Help us make the app better" / "Hjälp oss göra appen bättre" to Lokalise + title = "Help us make the app better", + // TODO: Add the body copy below (and its Swedish translation) to Lokalise + description = "We use technical tools to see how you use the app, so we can make it better.\n\n" + + "Product analytics is completely optional and can be turned off any time in settings. " + + "This data is never used for marketing.", + ) + Spacer(Modifier.weight(1f)) + // TODO: Add "Privacy policy" / "Integritetspolicy" to Lokalise + HedvigTextButton( + text = "Privacy policy", + onClick = openPrivacyPolicy, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + OnboardingStepButtons( + // TODO: Add "Allow" / "Tillåt" to Lokalise + primaryText = "Allow", + onPrimaryClick = { viewModel.emit(OnboardingConsentEvent.Allow) }, + // TODO: Add "Deny" / "Neka" to Lokalise + secondaryText = "Deny", + onSecondaryClick = { viewModel.emit(OnboardingConsentEvent.Deny) }, + ) + } + } + } +} diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/FakeSettingsDataStore.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/FakeSettingsDataStore.kt new file mode 100644 index 0000000000..686f39e159 --- /dev/null +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/FakeSettingsDataStore.kt @@ -0,0 +1,25 @@ +package com.hedvig.android.feature.onboarding + +import com.hedvig.android.data.settings.datastore.AnalyticsConsent +import com.hedvig.android.data.settings.datastore.SettingsDataStore +import com.hedvig.android.theme.Theme +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +internal class FakeSettingsDataStore : SettingsDataStore { + val consent = MutableStateFlow(AnalyticsConsent.NOT_DECIDED) + + override suspend fun setAnalyticsConsent(consent: AnalyticsConsent) { + this.consent.value = consent + } + + override fun observeAnalyticsConsent(): Flow = consent + + override suspend fun setTheme(theme: Theme) = error("unused") + + override fun observeTheme(): Flow = error("unused") + + override suspend fun setEmailSubscriptionPreference(subscribe: Boolean) = error("unused") + + override fun observeEmailSubscriptionPreference(): Flow = error("unused") +} diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingConsentPresenterTest.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingConsentPresenterTest.kt new file mode 100644 index 0000000000..137c98935d --- /dev/null +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingConsentPresenterTest.kt @@ -0,0 +1,77 @@ +package com.hedvig.android.feature.onboarding.ui.consent + +import androidx.compose.runtime.mutableStateListOf +import arrow.core.right +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import com.hedvig.android.data.settings.datastore.AnalyticsConsent +import com.hedvig.android.feature.onboarding.FakeOnboardingRepository +import com.hedvig.android.feature.onboarding.FakeSettingsDataStore +import com.hedvig.android.feature.onboarding.data.CompleteOnboardingUseCase +import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore +import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator +import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId +import com.hedvig.android.feature.onboarding.navigation.OnboardingStepKey +import com.hedvig.android.feature.onboarding.testOnboardingData +import com.hedvig.android.logger.TestLogcatLoggingRule +import com.hedvig.android.molecule.test.test +import com.hedvig.android.navigation.common.HedvigNavKey +import com.hedvig.android.navigation.compose.Backstack +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +internal class OnboardingConsentPresenterTest { + @get:Rule + val testLogcatRule = TestLogcatLoggingRule() + + private class TestBackstack : Backstack { + override val entries: MutableList = mutableStateListOf() + } + + private class NoopCompleteOnboardingUseCase : CompleteOnboardingUseCase { + override suspend fun invoke() {} + } + + @Test + fun `allow stores GRANTED and advances to the next step`() = runTest { + val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.AnalyticsConsent)) } + val repository = FakeOnboardingRepository() + val sessionStore = OnboardingSessionStore(repository) + val settingsDataStore = FakeSettingsDataStore() + val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) + val presenter = OnboardingConsentPresenter(sessionStore, navigator, settingsDataStore) + + presenter.test(OnboardingConsentUiState.Loading) { + skipItems(1) + repository.onboardingDataResponses.add(testOnboardingData().right()) + assertThat(awaitItem()).isInstanceOf() + sendEvent(OnboardingConsentEvent.Allow) + runCurrent() + assertThat(settingsDataStore.consent.value).isEqualTo(AnalyticsConsent.GRANTED) + assertThat(backstack.entries.last()).isEqualTo(OnboardingStepKey(OnboardingStepId.PhoneNumber)) + } + } + + @Test + fun `deny stores DENIED and still advances`() = runTest { + val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.AnalyticsConsent)) } + val repository = FakeOnboardingRepository() + val sessionStore = OnboardingSessionStore(repository) + val settingsDataStore = FakeSettingsDataStore() + val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) + val presenter = OnboardingConsentPresenter(sessionStore, navigator, settingsDataStore) + + presenter.test(OnboardingConsentUiState.Loading) { + skipItems(1) + repository.onboardingDataResponses.add(testOnboardingData().right()) + awaitItem() + sendEvent(OnboardingConsentEvent.Deny) + runCurrent() + assertThat(settingsDataStore.consent.value).isEqualTo(AnalyticsConsent.DENIED) + assertThat(backstack.entries.last()).isEqualTo(OnboardingStepKey(OnboardingStepId.PhoneNumber)) + } + } +} From d386fb09e1f5c44c6ada1bee6e8f8f98ce630a90 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 21 Jul 2026 16:55:20 +0200 Subject: [PATCH 017/119] Add onboarding phone number step --- .../ui/phone/OnboardingPhoneDestination.kt | 209 ++++++++++++++++++ .../onboarding/FakeOnboardingRepository.kt | 2 + .../ui/phone/OnboardingPhonePresenterTest.kt | 119 ++++++++++ 3 files changed, 330 insertions(+) create mode 100644 app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt create mode 100644 app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhonePresenterTest.kt diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt new file mode 100644 index 0000000000..6a9b390eba --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt @@ -0,0 +1,209 @@ +package com.hedvig.android.feature.onboarding.ui.phone + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.hedvig.android.core.common.di.ActivityRetainedScope +import com.hedvig.android.core.common.di.HedvigViewModel +import com.hedvig.android.design.system.hedvig.HedvigErrorSection +import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgressDebounced +import com.hedvig.android.design.system.hedvig.HedvigTextField +import com.hedvig.android.design.system.hedvig.HedvigTextFieldDefaults +import com.hedvig.android.feature.onboarding.data.OnboardingRepository +import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore +import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator +import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId +import com.hedvig.android.feature.onboarding.ui.OnboardingProgress +import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons +import com.hedvig.android.feature.onboarding.ui.OnboardingStepHeader +import com.hedvig.android.feature.onboarding.ui.OnboardingStepScaffold +import com.hedvig.android.feature.onboarding.ui.progressFor +import com.hedvig.android.molecule.public.MoleculePresenter +import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.molecule.public.MoleculeViewModel +import dev.zacsweers.metro.Inject +import kotlinx.coroutines.launch + +@Inject +@HedvigViewModel(ActivityRetainedScope::class) +internal class OnboardingPhoneViewModel( + sessionStore: OnboardingSessionStore, + navigator: OnboardingNavigator, + onboardingRepository: OnboardingRepository, +) : MoleculeViewModel( + initialState = OnboardingPhoneUiState.Loading, + presenter = OnboardingPhonePresenter(sessionStore, navigator, onboardingRepository), + ) + +internal class OnboardingPhonePresenter( + private val sessionStore: OnboardingSessionStore, + private val navigator: OnboardingNavigator, + private val onboardingRepository: OnboardingRepository, +) : MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present( + lastState: OnboardingPhoneUiState, + ): OnboardingPhoneUiState { + var currentState by remember { mutableStateOf(lastState) } + var loadIteration by remember { mutableIntStateOf(0) } + var submitIteration by remember { mutableIntStateOf(0) } + + LaunchedEffect(loadIteration) { + if (currentState is OnboardingPhoneUiState.Content) return@LaunchedEffect + currentState = OnboardingPhoneUiState.Loading + sessionStore.getOrFetchSession().fold( + ifLeft = { currentState = OnboardingPhoneUiState.Error }, + ifRight = { session -> + currentState = OnboardingPhoneUiState.Content( + progress = session.progressFor(OnboardingStepId.PhoneNumber), + phoneNumber = session.data.phoneNumber.orEmpty(), + ) + }, + ) + } + + LaunchedEffect(submitIteration) { + if (submitIteration == 0) return@LaunchedEffect + val content = currentState as? OnboardingPhoneUiState.Content ?: return@LaunchedEffect + val session = sessionStore.currentSession ?: return@LaunchedEffect + currentState = content.copy(isSubmitting = true, showSubmissionError = false) + onboardingRepository.updateContactInfo( + email = session.data.email, + phoneNumber = content.phoneNumber, + ).fold( + ifLeft = { + currentState = content.copy(isSubmitting = false, showSubmissionError = true) + }, + ifRight = { + navigator.continueFrom(OnboardingStepId.PhoneNumber) + }, + ) + } + + CollectEvents { event -> + when (event) { + OnboardingPhoneEvent.Retry -> { + loadIteration++ + } + + OnboardingPhoneEvent.Close -> { + launch { navigator.exitOnboarding() } + } + + is OnboardingPhoneEvent.UpdatePhoneNumber -> { + val content = currentState as? OnboardingPhoneUiState.Content ?: return@CollectEvents + currentState = content.copy(phoneNumber = event.phoneNumber, showSubmissionError = false) + } + + OnboardingPhoneEvent.Save -> { + submitIteration++ + } + + OnboardingPhoneEvent.DoThisLater -> { + launch { navigator.continueFrom(OnboardingStepId.PhoneNumber) } + } + } + } + + return currentState + } +} + +internal sealed interface OnboardingPhoneUiState { + data object Loading : OnboardingPhoneUiState + + data object Error : OnboardingPhoneUiState + + data class Content( + val progress: OnboardingProgress, + val phoneNumber: String, + val isSubmitting: Boolean = false, + val showSubmissionError: Boolean = false, + ) : OnboardingPhoneUiState +} + +internal sealed interface OnboardingPhoneEvent { + data object Retry : OnboardingPhoneEvent + + data object Close : OnboardingPhoneEvent + + data class UpdatePhoneNumber(val phoneNumber: String) : OnboardingPhoneEvent + + data object Save : OnboardingPhoneEvent + + data object DoThisLater : OnboardingPhoneEvent +} + +@Composable +internal fun OnboardingPhoneDestination(viewModel: OnboardingPhoneViewModel, navigateUp: () -> Unit) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + OnboardingStepScaffold( + progress = (uiState as? OnboardingPhoneUiState.Content)?.progress, + showBackButton = true, + onBackClick = navigateUp, + onCloseClick = { viewModel.emit(OnboardingPhoneEvent.Close) }, + ) { + when (val content = uiState) { + OnboardingPhoneUiState.Loading -> { + HedvigFullScreenCenterAlignedProgressDebounced() + } + + OnboardingPhoneUiState.Error -> { + HedvigErrorSection( + onButtonClick = { viewModel.emit(OnboardingPhoneEvent.Retry) }, + ) + } + + is OnboardingPhoneUiState.Content -> { + Spacer(Modifier.height(8.dp)) + OnboardingStepHeader( + // TODO: Add "Phone number" / "Telefonnummer" to Lokalise + title = "Phone number", + // TODO: Add "Add your phone number so we can reach you if something happens" / + // "Lägg till ditt telefonnummer så att vi kan nå dig om något händer" to Lokalise + description = "Add your phone number so we can reach you if something happens", + ) + Spacer(Modifier.height(16.dp)) + HedvigTextField( + text = content.phoneNumber, + onValueChange = { viewModel.emit(OnboardingPhoneEvent.UpdatePhoneNumber(it)) }, + // TODO: Add "Phone number" / "Telefonnummer" to Lokalise + labelText = "Phone number", + textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, + errorState = if (content.showSubmissionError) { + // TODO: Add "Could not save, please try again" / "Kunde inte spara, försök igen" to Lokalise + HedvigTextFieldDefaults.ErrorState.Error.WithMessage("Could not save, please try again") + } else { + HedvigTextFieldDefaults.ErrorState.NoError + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + OnboardingStepButtons( + // TODO: Add "Save" / "Spara" to Lokalise + primaryText = "Save", + onPrimaryClick = { viewModel.emit(OnboardingPhoneEvent.Save) }, + primaryEnabled = !content.isSubmitting && content.phoneNumber.isNotBlank(), + // TODO: Add "Do this later" / "Gör det senare" to Lokalise + secondaryText = "Do this later", + onSecondaryClick = { viewModel.emit(OnboardingPhoneEvent.DoThisLater) }, + ) + } + } + } +} diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/FakeOnboardingRepository.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/FakeOnboardingRepository.kt index f30e8f8002..e4a633e3cc 100644 --- a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/FakeOnboardingRepository.kt +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/FakeOnboardingRepository.kt @@ -12,12 +12,14 @@ import com.hedvig.android.feature.onboarding.data.OnboardingRepository internal class FakeOnboardingRepository : OnboardingRepository { val onboardingDataResponses = Turbine>() val updateContactInfoResponses = Turbine>() + var updateContactInfoCallCount: Int = 0 override suspend fun getOnboardingData(): Either { return onboardingDataResponses.awaitItem() } override suspend fun updateContactInfo(email: String, phoneNumber: String): Either { + updateContactInfoCallCount++ return updateContactInfoResponses.awaitItem() } } diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhonePresenterTest.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhonePresenterTest.kt new file mode 100644 index 0000000000..5372cdba82 --- /dev/null +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhonePresenterTest.kt @@ -0,0 +1,119 @@ +package com.hedvig.android.feature.onboarding.ui.phone + +import androidx.compose.runtime.mutableStateListOf +import arrow.core.left +import arrow.core.right +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import assertk.assertions.isTrue +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.feature.onboarding.FakeOnboardingRepository +import com.hedvig.android.feature.onboarding.data.CompleteOnboardingUseCase +import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore +import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator +import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId +import com.hedvig.android.feature.onboarding.navigation.OnboardingStepKey +import com.hedvig.android.feature.onboarding.testOnboardingData +import com.hedvig.android.logger.TestLogcatLoggingRule +import com.hedvig.android.molecule.test.test +import com.hedvig.android.navigation.common.HedvigNavKey +import com.hedvig.android.navigation.compose.Backstack +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +internal class OnboardingPhonePresenterTest { + @get:Rule + val testLogcatRule = TestLogcatLoggingRule() + + private class TestBackstack : Backstack { + override val entries: MutableList = mutableStateListOf() + } + + private class NoopCompleteOnboardingUseCase : CompleteOnboardingUseCase { + override suspend fun invoke() {} + } + + @Test + fun `content pre-fills the member's phone number`() = runTest { + val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.PhoneNumber)) } + val repository = FakeOnboardingRepository() + val sessionStore = OnboardingSessionStore(repository) + val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) + val presenter = OnboardingPhonePresenter(sessionStore, navigator, repository) + + presenter.test(OnboardingPhoneUiState.Loading) { + skipItems(1) + repository.onboardingDataResponses.add(testOnboardingData(phoneNumber = "070 990 12 32").right()) + val content = awaitItem() + assertThat(content).isInstanceOf() + assertThat((content as OnboardingPhoneUiState.Content).phoneNumber).isEqualTo("070 990 12 32") + } + } + + @Test + fun `save success advances to the next step`() = runTest { + val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.PhoneNumber)) } + val repository = FakeOnboardingRepository() + val sessionStore = OnboardingSessionStore(repository) + val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) + val presenter = OnboardingPhonePresenter(sessionStore, navigator, repository) + + presenter.test(OnboardingPhoneUiState.Loading) { + skipItems(1) + repository.onboardingDataResponses.add(testOnboardingData().right()) + awaitItem() + sendEvent(OnboardingPhoneEvent.UpdatePhoneNumber("0701234567")) + sendEvent(OnboardingPhoneEvent.Save) + runCurrent() + repository.updateContactInfoResponses.add(Unit.right()) + awaitItem() // isSubmitting = true + awaitItem() // navigation happens + assertThat(backstack.entries.last()).isEqualTo(OnboardingStepKey(OnboardingStepId.Theme)) + } + } + + @Test + fun `save failure shows inline error and does not advance`() = runTest { + val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.PhoneNumber)) } + val repository = FakeOnboardingRepository() + val sessionStore = OnboardingSessionStore(repository) + val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) + val presenter = OnboardingPhonePresenter(sessionStore, navigator, repository) + + presenter.test(OnboardingPhoneUiState.Loading) { + skipItems(1) + repository.onboardingDataResponses.add(testOnboardingData().right()) + awaitItem() + sendEvent(OnboardingPhoneEvent.Save) + runCurrent() + repository.updateContactInfoResponses.add(ErrorMessage("nope").left()) + awaitItem() // isSubmitting = true + val state = awaitItem() + assertThat(state).isInstanceOf() + assertThat((state as OnboardingPhoneUiState.Content).showSubmissionError).isTrue() + assertThat(backstack.entries.last()).isEqualTo(OnboardingStepKey(OnboardingStepId.PhoneNumber)) + } + } + + @Test + fun `do this later advances without calling the mutation`() = runTest { + val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.PhoneNumber)) } + val repository = FakeOnboardingRepository() + val sessionStore = OnboardingSessionStore(repository) + val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) + val presenter = OnboardingPhonePresenter(sessionStore, navigator, repository) + + presenter.test(OnboardingPhoneUiState.Loading) { + skipItems(1) + repository.onboardingDataResponses.add(testOnboardingData().right()) + awaitItem() + sendEvent(OnboardingPhoneEvent.DoThisLater) + runCurrent() + assertThat(backstack.entries.last()).isEqualTo(OnboardingStepKey(OnboardingStepId.Theme)) + assertThat(repository.updateContactInfoCallCount).isEqualTo(0) + } + } +} From 1101f8b0b939dadec9eca90a48e81b73e55285d0 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 21 Jul 2026 17:04:05 +0200 Subject: [PATCH 018/119] Make onboarding phone save-success test deterministic The two awaitItem() comments were swapped: the first consumed the UpdatePhoneNumber state change (not isSubmitting=true), the second consumed the isSubmitting=true state (not a navigation emission). Correct both comments so the test reads what it actually asserts. --- .../onboarding/ui/phone/OnboardingPhonePresenterTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhonePresenterTest.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhonePresenterTest.kt index 5372cdba82..1bf3a8296e 100644 --- a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhonePresenterTest.kt +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhonePresenterTest.kt @@ -69,8 +69,8 @@ internal class OnboardingPhonePresenterTest { sendEvent(OnboardingPhoneEvent.Save) runCurrent() repository.updateContactInfoResponses.add(Unit.right()) - awaitItem() // isSubmitting = true - awaitItem() // navigation happens + awaitItem() // phone number updated, not yet submitting + awaitItem() // isSubmitting = true; continueFrom already ran during runCurrent() above assertThat(backstack.entries.last()).isEqualTo(OnboardingStepKey(OnboardingStepId.Theme)) } } From e2f171026b72f625648553ed752a63d3b8aceda2 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 21 Jul 2026 17:10:47 +0200 Subject: [PATCH 019/119] Add onboarding theme step --- .../ui/theme/OnboardingThemeDestination.kt | 231 ++++++++++++++++++ .../onboarding/FakeSettingsDataStore.kt | 7 +- .../ui/theme/OnboardingThemePresenterTest.kt | 94 +++++++ 3 files changed, 330 insertions(+), 2 deletions(-) create mode 100644 app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt create mode 100644 app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemePresenterTest.kt diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt new file mode 100644 index 0000000000..8abb8dfe12 --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt @@ -0,0 +1,231 @@ +package com.hedvig.android.feature.onboarding.ui.theme + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.hedvig.android.core.common.di.ActivityRetainedScope +import com.hedvig.android.core.common.di.HedvigViewModel +import com.hedvig.android.data.settings.datastore.SettingsDataStore +import com.hedvig.android.design.system.hedvig.HedvigErrorSection +import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgressDebounced +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.Icon +import com.hedvig.android.design.system.hedvig.icon.Checkmark +import com.hedvig.android.design.system.hedvig.icon.HedvigIcons +import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore +import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator +import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId +import com.hedvig.android.feature.onboarding.ui.OnboardingProgress +import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons +import com.hedvig.android.feature.onboarding.ui.OnboardingStepHeader +import com.hedvig.android.feature.onboarding.ui.OnboardingStepScaffold +import com.hedvig.android.feature.onboarding.ui.progressFor +import com.hedvig.android.molecule.public.MoleculePresenter +import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.molecule.public.MoleculeViewModel +import com.hedvig.android.theme.Theme +import dev.zacsweers.metro.Inject +import hedvig.resources.Res +import hedvig.resources.SETTINGS_THEME_DARK +import hedvig.resources.SETTINGS_THEME_LIGHT +import hedvig.resources.SETTINGS_THEME_SYSTEM_DEFAULT +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource + +@Inject +@HedvigViewModel(ActivityRetainedScope::class) +internal class OnboardingThemeViewModel( + sessionStore: OnboardingSessionStore, + navigator: OnboardingNavigator, + settingsDataStore: SettingsDataStore, +) : MoleculeViewModel( + initialState = OnboardingThemeUiState.Loading, + presenter = OnboardingThemePresenter(sessionStore, navigator, settingsDataStore), + ) + +internal class OnboardingThemePresenter( + private val sessionStore: OnboardingSessionStore, + private val navigator: OnboardingNavigator, + private val settingsDataStore: SettingsDataStore, +) : MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present( + lastState: OnboardingThemeUiState, + ): OnboardingThemeUiState { + var currentState by remember { mutableStateOf(lastState) } + var loadIteration by remember { mutableIntStateOf(0) } + + val storedTheme = settingsDataStore.observeTheme().collectAsState(null).value + + LaunchedEffect(loadIteration) { + if (currentState is OnboardingThemeUiState.Content) return@LaunchedEffect + currentState = OnboardingThemeUiState.Loading + sessionStore.getOrFetchSession().fold( + ifLeft = { currentState = OnboardingThemeUiState.Error }, + ifRight = { session -> + currentState = OnboardingThemeUiState.Content( + progress = session.progressFor(OnboardingStepId.Theme), + selectedTheme = storedTheme ?: Theme.SYSTEM_DEFAULT, + ) + }, + ) + } + + CollectEvents { event -> + when (event) { + OnboardingThemeEvent.Retry -> loadIteration++ + OnboardingThemeEvent.Close -> launch { navigator.exitOnboarding() } + is OnboardingThemeEvent.SelectTheme -> launch { settingsDataStore.setTheme(event.theme) } + OnboardingThemeEvent.Continue -> launch { navigator.continueFrom(OnboardingStepId.Theme) } + } + } + + return if (currentState is OnboardingThemeUiState.Content) { + (currentState as OnboardingThemeUiState.Content).copy( + selectedTheme = storedTheme ?: Theme.SYSTEM_DEFAULT, + ) + } else { + currentState + } + } +} + +internal sealed interface OnboardingThemeUiState { + data object Loading : OnboardingThemeUiState + + data object Error : OnboardingThemeUiState + + data class Content( + val progress: OnboardingProgress, + val selectedTheme: Theme, + ) : OnboardingThemeUiState +} + +internal sealed interface OnboardingThemeEvent { + data object Retry : OnboardingThemeEvent + + data object Close : OnboardingThemeEvent + + data class SelectTheme(val theme: Theme) : OnboardingThemeEvent + + data object Continue : OnboardingThemeEvent +} + +@Composable +internal fun OnboardingThemeDestination(viewModel: OnboardingThemeViewModel, navigateUp: () -> Unit) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + OnboardingStepScaffold( + progress = (uiState as? OnboardingThemeUiState.Content)?.progress, + showBackButton = true, + onBackClick = navigateUp, + onCloseClick = { viewModel.emit(OnboardingThemeEvent.Close) }, + ) { + when (uiState) { + OnboardingThemeUiState.Loading -> { + HedvigFullScreenCenterAlignedProgressDebounced() + } + + OnboardingThemeUiState.Error -> { + HedvigErrorSection( + onButtonClick = { viewModel.emit(OnboardingThemeEvent.Retry) }, + ) + } + + is OnboardingThemeUiState.Content -> { + val content = uiState as OnboardingThemeUiState.Content + Spacer(Modifier.height(8.dp)) + OnboardingStepHeader( + // TODO: Add "Choose theme" / "Välj tema" to Lokalise + title = "Choose theme", + // TODO: Add "Customize the look of the app" / "Anpassa utseendet på appen" to Lokalise + description = "Customize the look of the app", + ) + Spacer(Modifier.height(16.dp)) + ThemeOptionRow( + title = stringResource(Res.string.SETTINGS_THEME_SYSTEM_DEFAULT), + // TODO: Add "Uses your phone's setting" / "Använder din telefons inställning" to Lokalise + subtitle = "Uses your phone's setting", + isSelected = content.selectedTheme == Theme.SYSTEM_DEFAULT, + onClick = { viewModel.emit(OnboardingThemeEvent.SelectTheme(Theme.SYSTEM_DEFAULT)) }, + modifier = Modifier.padding(horizontal = 16.dp), + ) + ThemeOptionRow( + title = stringResource(Res.string.SETTINGS_THEME_LIGHT), + // TODO: Add "Set light mode" / "Ange ljust läge" to Lokalise + subtitle = "Set light mode", + isSelected = content.selectedTheme == Theme.LIGHT, + onClick = { viewModel.emit(OnboardingThemeEvent.SelectTheme(Theme.LIGHT)) }, + modifier = Modifier.padding(horizontal = 16.dp), + ) + ThemeOptionRow( + title = stringResource(Res.string.SETTINGS_THEME_DARK), + // TODO: Add "Set dark mode" / "Ange mörkt läge" to Lokalise + subtitle = "Set dark mode", + isSelected = content.selectedTheme == Theme.DARK, + onClick = { viewModel.emit(OnboardingThemeEvent.SelectTheme(Theme.DARK)) }, + modifier = Modifier.padding(horizontal = 16.dp), + ) + Spacer(Modifier.weight(1f)) + HedvigText( + // TODO: Add "You can change these settings later" / "Du kan ändra dessa inställningar senare" to Lokalise + text = "You can change these settings later", + color = HedvigTheme.colorScheme.textSecondary, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + OnboardingStepButtons( + // TODO: Add "Continue" / "Fortsätt" to Lokalise + primaryText = "Continue", + onPrimaryClick = { viewModel.emit(OnboardingThemeEvent.Continue) }, + ) + } + } + } +} + +@Composable +private fun ThemeOptionRow( + title: String, + subtitle: String, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxWidth() + .clip(HedvigTheme.shapes.cornerMedium) + .clickable(onClick = onClick) + .padding(16.dp), + ) { + Column(Modifier.weight(1f)) { + HedvigText(title) + HedvigText(subtitle, color = HedvigTheme.colorScheme.textSecondary) + } + if (isSelected) { + Icon(imageVector = HedvigIcons.Checkmark, contentDescription = null) + } + } +} diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/FakeSettingsDataStore.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/FakeSettingsDataStore.kt index 686f39e159..1b47636b8b 100644 --- a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/FakeSettingsDataStore.kt +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/FakeSettingsDataStore.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.MutableStateFlow internal class FakeSettingsDataStore : SettingsDataStore { val consent = MutableStateFlow(AnalyticsConsent.NOT_DECIDED) + val theme = MutableStateFlow(null) override suspend fun setAnalyticsConsent(consent: AnalyticsConsent) { this.consent.value = consent @@ -15,9 +16,11 @@ internal class FakeSettingsDataStore : SettingsDataStore { override fun observeAnalyticsConsent(): Flow = consent - override suspend fun setTheme(theme: Theme) = error("unused") + override suspend fun setTheme(theme: Theme) { + this.theme.value = theme + } - override fun observeTheme(): Flow = error("unused") + override fun observeTheme(): Flow = theme override suspend fun setEmailSubscriptionPreference(subscribe: Boolean) = error("unused") diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemePresenterTest.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemePresenterTest.kt new file mode 100644 index 0000000000..592f2529cb --- /dev/null +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemePresenterTest.kt @@ -0,0 +1,94 @@ +package com.hedvig.android.feature.onboarding.ui.theme + +import androidx.compose.runtime.mutableStateListOf +import arrow.core.right +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import com.hedvig.android.feature.onboarding.FakeOnboardingRepository +import com.hedvig.android.feature.onboarding.FakeSettingsDataStore +import com.hedvig.android.feature.onboarding.data.CompleteOnboardingUseCase +import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore +import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator +import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId +import com.hedvig.android.feature.onboarding.navigation.OnboardingStepKey +import com.hedvig.android.feature.onboarding.testOnboardingData +import com.hedvig.android.logger.TestLogcatLoggingRule +import com.hedvig.android.molecule.test.test +import com.hedvig.android.navigation.common.HedvigNavKey +import com.hedvig.android.navigation.compose.Backstack +import com.hedvig.android.theme.Theme +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +internal class OnboardingThemePresenterTest { + @get:Rule + val testLogcatRule = TestLogcatLoggingRule() + + private class TestBackstack : Backstack { + override val entries: MutableList = mutableStateListOf() + } + + private class NoopCompleteOnboardingUseCase : CompleteOnboardingUseCase { + override suspend fun invoke() {} + } + + @Test + fun `selected theme defaults to SYSTEM_DEFAULT when nothing stored`() = runTest { + val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.Theme)) } + val repository = FakeOnboardingRepository() + val sessionStore = OnboardingSessionStore(repository) + val settingsDataStore = FakeSettingsDataStore() + val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) + val presenter = OnboardingThemePresenter(sessionStore, navigator, settingsDataStore) + + presenter.test(OnboardingThemeUiState.Loading) { + skipItems(1) + repository.onboardingDataResponses.add(testOnboardingData().right()) + val content = awaitItem() + assertThat(content).isInstanceOf() + assertThat((content as OnboardingThemeUiState.Content).selectedTheme).isEqualTo(Theme.SYSTEM_DEFAULT) + } + } + + @Test + fun `selecting a theme persists it and updates the selection`() = runTest { + val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.Theme)) } + val repository = FakeOnboardingRepository() + val sessionStore = OnboardingSessionStore(repository) + val settingsDataStore = FakeSettingsDataStore() + val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) + val presenter = OnboardingThemePresenter(sessionStore, navigator, settingsDataStore) + + presenter.test(OnboardingThemeUiState.Loading) { + skipItems(1) + repository.onboardingDataResponses.add(testOnboardingData().right()) + awaitItem() + sendEvent(OnboardingThemeEvent.SelectTheme(Theme.DARK)) + runCurrent() + assertThat(settingsDataStore.theme.value).isEqualTo(Theme.DARK) + assertThat((awaitItem() as OnboardingThemeUiState.Content).selectedTheme).isEqualTo(Theme.DARK) + } + } + + @Test + fun `continue advances to the next path step`() = runTest { + val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.Theme)) } + val repository = FakeOnboardingRepository() + val sessionStore = OnboardingSessionStore(repository) + val settingsDataStore = FakeSettingsDataStore() + val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) + val presenter = OnboardingThemePresenter(sessionStore, navigator, settingsDataStore) + + presenter.test(OnboardingThemeUiState.Loading) { + skipItems(1) + repository.onboardingDataResponses.add(testOnboardingData().right()) + awaitItem() + sendEvent(OnboardingThemeEvent.Continue) + runCurrent() + assertThat(backstack.entries.last()).isEqualTo(OnboardingStepKey(OnboardingStepId.CoInsured)) + } + } +} From ff63278b49fc35a2755266a9e07be1a283bc62d8 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 21 Jul 2026 17:20:04 +0200 Subject: [PATCH 020/119] Add onboarding co-insured step --- .../feature-onboarding/build.gradle.kts | 1 + .../navigation/OnboardingNavigator.kt | 7 + .../OnboardingCoInsuredDestination.kt | 236 ++++++++++++++++++ .../OnboardingCoInsuredPresenterTest.kt | 131 ++++++++++ 4 files changed, 375 insertions(+) create mode 100644 app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt create mode 100644 app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredPresenterTest.kt diff --git a/app/feature/feature-onboarding/build.gradle.kts b/app/feature/feature-onboarding/build.gradle.kts index 18684a8e16..6f493ac17a 100644 --- a/app/feature/feature-onboarding/build.gradle.kts +++ b/app/feature/feature-onboarding/build.gradle.kts @@ -31,6 +31,7 @@ dependencies { implementation(projects.coreResources) implementation(projects.dataSettingsDatastorePublic) implementation(projects.designSystemHedvig) + implementation(projects.dataCoinsured) implementation(projects.featureConnectPaymentTrustlyNavigation) implementation(projects.featureEditCoinsuredNavigation) implementation(projects.moleculePublic) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingNavigator.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingNavigator.kt index 015fdd132a..ce6c4118dc 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingNavigator.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingNavigator.kt @@ -1,6 +1,8 @@ package com.hedvig.android.feature.onboarding.navigation import com.hedvig.android.core.common.di.ActivityRetainedScope +import com.hedvig.android.data.coinsured.CoInsuredFlowType +import com.hedvig.android.feature.editcoinsured.navigation.CoInsuredAddInfoKey import com.hedvig.android.feature.onboarding.data.CompleteOnboardingUseCase import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.logger.logcat @@ -49,6 +51,11 @@ internal class OnboardingNavigator( backstack.removeAllOf() } + /** Pushes the existing edit-co-insured flow; it pops itself back here when done. */ + fun openAddCoInsured(contractId: String) { + backstack.add(CoInsuredAddInfoKey(contractId, CoInsuredFlowType.CoInsured)) + } + /** Reserved for the top app bar back arrow only, per the navigation rule. */ fun navigateBack() { backstack.navigateUp() diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt new file mode 100644 index 0000000000..c35123ea27 --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt @@ -0,0 +1,236 @@ +package com.hedvig.android.feature.onboarding.ui.coinsured + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.hedvig.android.core.common.di.ActivityRetainedScope +import com.hedvig.android.core.common.di.HedvigViewModel +import com.hedvig.android.design.system.hedvig.ButtonDefaults +import com.hedvig.android.design.system.hedvig.HedvigButton +import com.hedvig.android.design.system.hedvig.HedvigErrorSection +import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgressDebounced +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.Icon +import com.hedvig.android.design.system.hedvig.icon.Checkmark +import com.hedvig.android.design.system.hedvig.icon.HedvigIcons +import com.hedvig.android.feature.onboarding.data.OnboardingSession +import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore +import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator +import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId +import com.hedvig.android.feature.onboarding.ui.OnboardingProgress +import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons +import com.hedvig.android.feature.onboarding.ui.OnboardingStepHeader +import com.hedvig.android.feature.onboarding.ui.OnboardingStepScaffold +import com.hedvig.android.feature.onboarding.ui.progressFor +import com.hedvig.android.molecule.public.MoleculePresenter +import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.molecule.public.MoleculeViewModel +import dev.zacsweers.metro.Inject +import kotlinx.coroutines.launch + +@Inject +@HedvigViewModel(ActivityRetainedScope::class) +internal class OnboardingCoInsuredViewModel( + sessionStore: OnboardingSessionStore, + navigator: OnboardingNavigator, +) : MoleculeViewModel( + initialState = OnboardingCoInsuredUiState.Loading, + presenter = OnboardingCoInsuredPresenter(sessionStore, navigator), + ) + +internal class OnboardingCoInsuredPresenter( + private val sessionStore: OnboardingSessionStore, + private val navigator: OnboardingNavigator, +) : MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present( + lastState: OnboardingCoInsuredUiState, + ): OnboardingCoInsuredUiState { + var currentState by remember { mutableStateOf(lastState) } + var loadIteration by remember { mutableIntStateOf(0) } + + // Pin which contracts belong to this step the first time data is available, so completed + // rows render as done instead of disappearing. + var pinnedContractIds by remember { mutableStateOf?>(null) } + + fun contentFrom(session: OnboardingSession): OnboardingCoInsuredUiState.Content { + val ids = pinnedContractIds + ?: session.data.contractsWithMissingCoInsured.map { it.id }.also { pinnedContractIds = it } + return OnboardingCoInsuredUiState.Content( + progress = session.progressFor(OnboardingStepId.CoInsured), + rows = ids.mapNotNull { id -> + val contract = session.data.contracts.firstOrNull { it.id == id } ?: return@mapNotNull null + CoInsuredRow( + contractId = contract.id, + displayName = contract.displayName, + exposureName = contract.exposureName, + isComplete = contract.missingCoInsuredCount == 0, + ) + }, + ) + } + + LaunchedEffect(loadIteration) { + if (currentState is OnboardingCoInsuredUiState.Content) return@LaunchedEffect + currentState = OnboardingCoInsuredUiState.Loading + sessionStore.getOrFetchSession().fold( + ifLeft = { currentState = OnboardingCoInsuredUiState.Error }, + ifRight = { session -> currentState = contentFrom(session) }, + ) + } + + CollectEvents { event -> + when (event) { + OnboardingCoInsuredEvent.Retry -> loadIteration++ + + OnboardingCoInsuredEvent.Close -> launch { navigator.exitOnboarding() } + + OnboardingCoInsuredEvent.Refresh -> launch { + sessionStore.refreshData().onRight { refreshed -> currentState = contentFrom(refreshed) } + // onLeft: keep the previously shown state, per the spec's error handling. + } + + is OnboardingCoInsuredEvent.AddCoInsured -> navigator.openAddCoInsured(event.contractId) + + OnboardingCoInsuredEvent.Continue -> launch { navigator.continueFrom(OnboardingStepId.CoInsured) } + } + } + + return currentState + } +} + +internal data class CoInsuredRow( + val contractId: String, + val displayName: String, + val exposureName: String, + val isComplete: Boolean, +) + +internal sealed interface OnboardingCoInsuredUiState { + data object Loading : OnboardingCoInsuredUiState + + data object Error : OnboardingCoInsuredUiState + + data class Content( + val progress: OnboardingProgress, + val rows: List, + ) : OnboardingCoInsuredUiState +} + +internal sealed interface OnboardingCoInsuredEvent { + data object Retry : OnboardingCoInsuredEvent + + data object Close : OnboardingCoInsuredEvent + + data object Refresh : OnboardingCoInsuredEvent + + data class AddCoInsured(val contractId: String) : OnboardingCoInsuredEvent + + data object Continue : OnboardingCoInsuredEvent +} + +@Composable +internal fun OnboardingCoInsuredDestination(viewModel: OnboardingCoInsuredViewModel, navigateUp: () -> Unit) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + var hasResumedOnce by rememberSaveable { mutableStateOf(false) } + LifecycleResumeEffect(Unit) { + if (hasResumedOnce) { + viewModel.emit(OnboardingCoInsuredEvent.Refresh) + } else { + hasResumedOnce = true + } + onPauseOrDispose {} + } + + OnboardingStepScaffold( + progress = (uiState as? OnboardingCoInsuredUiState.Content)?.progress, + showBackButton = true, + onBackClick = navigateUp, + onCloseClick = { viewModel.emit(OnboardingCoInsuredEvent.Close) }, + ) { + when (val content = uiState) { + OnboardingCoInsuredUiState.Loading -> { + HedvigFullScreenCenterAlignedProgressDebounced() + } + + OnboardingCoInsuredUiState.Error -> { + HedvigErrorSection( + onButtonClick = { viewModel.emit(OnboardingCoInsuredEvent.Retry) }, + ) + } + + is OnboardingCoInsuredUiState.Content -> { + Spacer(Modifier.height(8.dp)) + OnboardingStepHeader( + // TODO: Add "Add co-insured" / "Lägg till medförsäkrade" to Lokalise + title = "Add co-insured", + // TODO: Add "So we know who's covered by your insurance" / "Så att vi vet vem som är försäkrad" to Lokalise + description = "So we know who's covered by your insurance", + ) + Spacer(Modifier.height(16.dp)) + for (row in content.rows) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Column(Modifier.weight(1f)) { + HedvigText(row.displayName) + HedvigText(row.exposureName, color = HedvigTheme.colorScheme.textSecondary) + } + if (row.isComplete) { + Icon(imageVector = HedvigIcons.Checkmark, contentDescription = null) + } else { + HedvigButton( + // TODO: Add "Add" / "Lägg till" to Lokalise + text = "Add", + onClick = { viewModel.emit(OnboardingCoInsuredEvent.AddCoInsured(row.contractId)) }, + enabled = true, + buttonSize = ButtonDefaults.ButtonSize.Small, + ) + } + } + } + Spacer(Modifier.weight(1f)) + HedvigText( + // TODO: Add "You can add this information later" / "Du kan lägga till den här informationen senare" to Lokalise + text = "You can add this information later", + color = HedvigTheme.colorScheme.textSecondary, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + OnboardingStepButtons( + // TODO: Add "Continue" / "Fortsätt" to Lokalise + primaryText = "Continue", + onPrimaryClick = { viewModel.emit(OnboardingCoInsuredEvent.Continue) }, + // TODO: Add "Do this later" / "Gör det senare" to Lokalise + secondaryText = "Do this later", + onSecondaryClick = { viewModel.emit(OnboardingCoInsuredEvent.Continue) }, + ) + } + } + } +} diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredPresenterTest.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredPresenterTest.kt new file mode 100644 index 0000000000..7f256e0e0b --- /dev/null +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredPresenterTest.kt @@ -0,0 +1,131 @@ +package com.hedvig.android.feature.onboarding.ui.coinsured + +import androidx.compose.runtime.mutableStateListOf +import arrow.core.left +import arrow.core.right +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.data.coinsured.CoInsuredFlowType +import com.hedvig.android.feature.editcoinsured.navigation.CoInsuredAddInfoKey +import com.hedvig.android.feature.onboarding.FakeOnboardingRepository +import com.hedvig.android.feature.onboarding.data.CompleteOnboardingUseCase +import com.hedvig.android.feature.onboarding.data.OnboardingContract +import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore +import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator +import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId +import com.hedvig.android.feature.onboarding.navigation.OnboardingStepKey +import com.hedvig.android.feature.onboarding.testOnboardingData +import com.hedvig.android.logger.TestLogcatLoggingRule +import com.hedvig.android.molecule.test.test +import com.hedvig.android.navigation.common.HedvigNavKey +import com.hedvig.android.navigation.compose.Backstack +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +internal class OnboardingCoInsuredPresenterTest { + @get:Rule + val testLogcatRule = TestLogcatLoggingRule() + + private class TestBackstack : Backstack { + override val entries: MutableList = mutableStateListOf() + } + + private class NoopCompleteOnboardingUseCase : CompleteOnboardingUseCase { + override suspend fun invoke() {} + } + + @Test + fun `rows are pinned from the contracts missing co-insured at load`() = runTest { + val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.CoInsured)) } + val repository = FakeOnboardingRepository() + val sessionStore = OnboardingSessionStore(repository) + val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) + val presenter = OnboardingCoInsuredPresenter(sessionStore, navigator) + + presenter.test(OnboardingCoInsuredUiState.Loading) { + skipItems(1) + repository.onboardingDataResponses.add(testOnboardingData().right()) + val state = awaitItem() + assertThat(state).isInstanceOf() + val content = state as OnboardingCoInsuredUiState.Content + assertThat(content.rows.size).isEqualTo(1) + assertThat(content.rows[0].isComplete).isEqualTo(false) + } + } + + @Test + fun `add navigates to the edit co-insured flow`() = runTest { + val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.CoInsured)) } + val repository = FakeOnboardingRepository() + val sessionStore = OnboardingSessionStore(repository) + val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) + val presenter = OnboardingCoInsuredPresenter(sessionStore, navigator) + + presenter.test(OnboardingCoInsuredUiState.Loading) { + skipItems(1) + repository.onboardingDataResponses.add(testOnboardingData().right()) + awaitItem() + sendEvent(OnboardingCoInsuredEvent.AddCoInsured("contract-1")) + runCurrent() + assertThat(backstack.entries.last()).isEqualTo(CoInsuredAddInfoKey("contract-1", CoInsuredFlowType.CoInsured)) + } + } + + @Test + fun `refresh marks completed rows instead of removing them`() = runTest { + val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.CoInsured)) } + val repository = FakeOnboardingRepository() + val sessionStore = OnboardingSessionStore(repository) + val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) + val presenter = OnboardingCoInsuredPresenter(sessionStore, navigator) + + presenter.test(OnboardingCoInsuredUiState.Loading) { + skipItems(1) + repository.onboardingDataResponses.add(testOnboardingData().right()) + awaitItem() + sendEvent(OnboardingCoInsuredEvent.Refresh) + runCurrent() + repository.onboardingDataResponses.add( + testOnboardingData( + contracts = listOf( + OnboardingContract( + id = "contract-1", + displayName = "Home Insurance", + exposureName = "Bellmansgatan 19A", + typeOfContract = "SE_APARTMENT_RENT", + missingCoInsuredCount = 0, + isMissingPetId = false, + ), + ), + ).right(), + ) + val state = awaitItem() + assertThat(state).isInstanceOf() + val content = state as OnboardingCoInsuredUiState.Content + assertThat(content.rows.size).isEqualTo(1) + assertThat(content.rows[0].isComplete).isEqualTo(true) + } + } + + @Test + fun `continue advances to InviteFriend (PetIds skipped for default data)`() = runTest { + val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.CoInsured)) } + val repository = FakeOnboardingRepository() + val sessionStore = OnboardingSessionStore(repository) + val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) + val presenter = OnboardingCoInsuredPresenter(sessionStore, navigator) + + presenter.test(OnboardingCoInsuredUiState.Loading) { + skipItems(1) + repository.onboardingDataResponses.add(testOnboardingData().right()) + awaitItem() + sendEvent(OnboardingCoInsuredEvent.Continue) + runCurrent() + assertThat(backstack.entries.last()).isEqualTo(OnboardingStepKey(OnboardingStepId.InviteFriend)) + } + } +} From 07e5a7919f56902efc941e8f9da81a63deea8a1d Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 21 Jul 2026 17:27:25 +0200 Subject: [PATCH 021/119] Add onboarding pet id step --- .../ui/petid/OnboardingPetIdDestination.kt | 236 ++++++++++++++++++ .../ui/petid/OnboardingPetIdPresenterTest.kt | 148 +++++++++++ 2 files changed, 384 insertions(+) create mode 100644 app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdDestination.kt create mode 100644 app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdPresenterTest.kt diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdDestination.kt new file mode 100644 index 0000000000..957304b1b1 --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdDestination.kt @@ -0,0 +1,236 @@ +package com.hedvig.android.feature.onboarding.ui.petid + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.hedvig.android.core.common.di.ActivityRetainedScope +import com.hedvig.android.core.common.di.HedvigViewModel +import com.hedvig.android.design.system.hedvig.ButtonDefaults +import com.hedvig.android.design.system.hedvig.HedvigButton +import com.hedvig.android.design.system.hedvig.HedvigErrorSection +import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgressDebounced +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.Icon +import com.hedvig.android.design.system.hedvig.icon.Checkmark +import com.hedvig.android.design.system.hedvig.icon.HedvigIcons +import com.hedvig.android.feature.onboarding.data.OnboardingSession +import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore +import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator +import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId +import com.hedvig.android.feature.onboarding.ui.OnboardingProgress +import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons +import com.hedvig.android.feature.onboarding.ui.OnboardingStepHeader +import com.hedvig.android.feature.onboarding.ui.OnboardingStepScaffold +import com.hedvig.android.feature.onboarding.ui.progressFor +import com.hedvig.android.molecule.public.MoleculePresenter +import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.molecule.public.MoleculeViewModel +import dev.zacsweers.metro.Inject +import kotlinx.coroutines.launch + +@Inject +@HedvigViewModel(ActivityRetainedScope::class) +internal class OnboardingPetIdViewModel( + sessionStore: OnboardingSessionStore, + navigator: OnboardingNavigator, +) : MoleculeViewModel( + initialState = OnboardingPetIdUiState.Loading, + presenter = OnboardingPetIdPresenter(sessionStore, navigator), + ) + +internal class OnboardingPetIdPresenter( + private val sessionStore: OnboardingSessionStore, + private val navigator: OnboardingNavigator, +) : MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present( + lastState: OnboardingPetIdUiState, + ): OnboardingPetIdUiState { + var currentState by remember { mutableStateOf(lastState) } + var loadIteration by remember { mutableIntStateOf(0) } + + // Pin which contracts belong to this step the first time data is available, so completed + // rows render as done instead of disappearing. + var pinnedContractIds by remember { mutableStateOf?>(null) } + + fun contentFrom(session: OnboardingSession): OnboardingPetIdUiState.Content { + val ids = pinnedContractIds + ?: session.data.contractsWithMissingPetId.map { it.id }.also { pinnedContractIds = it } + return OnboardingPetIdUiState.Content( + progress = session.progressFor(OnboardingStepId.PetIds), + rows = ids.mapNotNull { id -> + val contract = session.data.contracts.firstOrNull { it.id == id } ?: return@mapNotNull null + PetIdRow( + contractId = contract.id, + displayName = contract.displayName, + exposureName = contract.exposureName, + isComplete = !contract.isMissingPetId, + ) + }, + ) + } + + LaunchedEffect(loadIteration) { + if (currentState is OnboardingPetIdUiState.Content) return@LaunchedEffect + currentState = OnboardingPetIdUiState.Loading + sessionStore.getOrFetchSession().fold( + ifLeft = { currentState = OnboardingPetIdUiState.Error }, + ifRight = { session -> currentState = contentFrom(session) }, + ) + } + + CollectEvents { event -> + when (event) { + OnboardingPetIdEvent.Retry -> loadIteration++ + + OnboardingPetIdEvent.Close -> launch { navigator.exitOnboarding() } + + OnboardingPetIdEvent.Refresh -> launch { + sessionStore.refreshData().onRight { refreshed -> currentState = contentFrom(refreshed) } + // onLeft: keep the previously shown state, per the spec's error handling. + } + + OnboardingPetIdEvent.Continue -> launch { navigator.continueFrom(OnboardingStepId.PetIds) } + } + } + + return currentState + } +} + +internal data class PetIdRow( + val contractId: String, + val displayName: String, + val exposureName: String, + val isComplete: Boolean, +) + +internal sealed interface OnboardingPetIdUiState { + data object Loading : OnboardingPetIdUiState + + data object Error : OnboardingPetIdUiState + + data class Content( + val progress: OnboardingProgress, + val rows: List, + ) : OnboardingPetIdUiState +} + +internal sealed interface OnboardingPetIdEvent { + data object Retry : OnboardingPetIdEvent + + data object Close : OnboardingPetIdEvent + + data object Refresh : OnboardingPetIdEvent + + data object Continue : OnboardingPetIdEvent +} + +@Composable +internal fun OnboardingPetIdDestination( + viewModel: OnboardingPetIdViewModel, + navigateUp: () -> Unit, + onAddPetId: (contractId: String) -> Unit, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + var hasResumedOnce by rememberSaveable { mutableStateOf(false) } + LifecycleResumeEffect(Unit) { + if (hasResumedOnce) { + viewModel.emit(OnboardingPetIdEvent.Refresh) + } else { + hasResumedOnce = true + } + onPauseOrDispose {} + } + + OnboardingStepScaffold( + progress = (uiState as? OnboardingPetIdUiState.Content)?.progress, + showBackButton = true, + onBackClick = navigateUp, + onCloseClick = { viewModel.emit(OnboardingPetIdEvent.Close) }, + ) { + when (val content = uiState) { + OnboardingPetIdUiState.Loading -> { + HedvigFullScreenCenterAlignedProgressDebounced() + } + + OnboardingPetIdUiState.Error -> { + HedvigErrorSection( + onButtonClick = { viewModel.emit(OnboardingPetIdEvent.Retry) }, + ) + } + + is OnboardingPetIdUiState.Content -> { + Spacer(Modifier.height(8.dp)) + OnboardingStepHeader( + // TODO: Add "Add your pets ID numbers" / "Lägg till dina djurs ID-nummer" to Lokalise + title = "Add your pets ID numbers", + // TODO: Add "This makes it easier to help you if something happens" / "Det gör det lättare att hjälpa dig om något händer" to Lokalise + description = "This makes it easier to help you if something happens", + ) + Spacer(Modifier.height(16.dp)) + for (row in content.rows) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Column(Modifier.weight(1f)) { + HedvigText(row.displayName) + HedvigText(row.exposureName, color = HedvigTheme.colorScheme.textSecondary) + } + if (row.isComplete) { + Icon(imageVector = HedvigIcons.Checkmark, contentDescription = null) + } else { + HedvigButton( + // TODO: Add "Add" / "Lägg till" to Lokalise + text = "Add", + onClick = { onAddPetId(row.contractId) }, + enabled = true, + buttonSize = ButtonDefaults.ButtonSize.Small, + ) + } + } + } + Spacer(Modifier.weight(1f)) + HedvigText( + // TODO: Add "You can add this information later" / "Du kan lägga till den här informationen senare" to Lokalise + text = "You can add this information later", + color = HedvigTheme.colorScheme.textSecondary, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + OnboardingStepButtons( + // TODO: Add "Continue" / "Fortsätt" to Lokalise + primaryText = "Continue", + onPrimaryClick = { viewModel.emit(OnboardingPetIdEvent.Continue) }, + // TODO: Add "Do this later" / "Gör det senare" to Lokalise + secondaryText = "Do this later", + onSecondaryClick = { viewModel.emit(OnboardingPetIdEvent.Continue) }, + ) + } + } + } +} diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdPresenterTest.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdPresenterTest.kt new file mode 100644 index 0000000000..0378b3c7a5 --- /dev/null +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdPresenterTest.kt @@ -0,0 +1,148 @@ +package com.hedvig.android.feature.onboarding.ui.petid + +import androidx.compose.runtime.mutableStateListOf +import arrow.core.right +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import com.hedvig.android.feature.onboarding.FakeOnboardingRepository +import com.hedvig.android.feature.onboarding.data.CompleteOnboardingUseCase +import com.hedvig.android.feature.onboarding.data.OnboardingContract +import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore +import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator +import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId +import com.hedvig.android.feature.onboarding.navigation.OnboardingStepKey +import com.hedvig.android.feature.onboarding.testOnboardingData +import com.hedvig.android.logger.TestLogcatLoggingRule +import com.hedvig.android.molecule.test.test +import com.hedvig.android.navigation.common.HedvigNavKey +import com.hedvig.android.navigation.compose.Backstack +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +internal class OnboardingPetIdPresenterTest { + @get:Rule + val testLogcatRule = TestLogcatLoggingRule() + + private class TestBackstack : Backstack { + override val entries: MutableList = mutableStateListOf() + } + + private class NoopCompleteOnboardingUseCase : CompleteOnboardingUseCase { + override suspend fun invoke() {} + } + + @Test + fun `rows are pinned from the contracts missing pet id at load`() = runTest { + val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.PetIds)) } + val repository = FakeOnboardingRepository() + val sessionStore = OnboardingSessionStore(repository) + val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) + val presenter = OnboardingPetIdPresenter(sessionStore, navigator) + + presenter.test(OnboardingPetIdUiState.Loading) { + skipItems(1) + repository.onboardingDataResponses.add( + testOnboardingData( + contracts = listOf( + OnboardingContract( + id = "contract-1", + displayName = "Home Insurance", + exposureName = "Bellmansgatan 19A", + typeOfContract = "SE_APARTMENT_RENT", + missingCoInsuredCount = 0, + isMissingPetId = true, + ), + ), + ).right(), + ) + val state = awaitItem() + assertThat(state).isInstanceOf() + val content = state as OnboardingPetIdUiState.Content + assertThat(content.rows.size).isEqualTo(1) + assertThat(content.rows[0].isComplete).isEqualTo(false) + } + } + + @Test + fun `refresh marks completed rows instead of removing them`() = runTest { + val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.PetIds)) } + val repository = FakeOnboardingRepository() + val sessionStore = OnboardingSessionStore(repository) + val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) + val presenter = OnboardingPetIdPresenter(sessionStore, navigator) + + presenter.test(OnboardingPetIdUiState.Loading) { + skipItems(1) + repository.onboardingDataResponses.add( + testOnboardingData( + contracts = listOf( + OnboardingContract( + id = "contract-1", + displayName = "Home Insurance", + exposureName = "Bellmansgatan 19A", + typeOfContract = "SE_APARTMENT_RENT", + missingCoInsuredCount = 0, + isMissingPetId = true, + ), + ), + ).right(), + ) + awaitItem() + sendEvent(OnboardingPetIdEvent.Refresh) + runCurrent() + repository.onboardingDataResponses.add( + testOnboardingData( + contracts = listOf( + OnboardingContract( + id = "contract-1", + displayName = "Home Insurance", + exposureName = "Bellmansgatan 19A", + typeOfContract = "SE_APARTMENT_RENT", + missingCoInsuredCount = 0, + isMissingPetId = false, + ), + ), + ).right(), + ) + val state = awaitItem() + assertThat(state).isInstanceOf() + val content = state as OnboardingPetIdUiState.Content + assertThat(content.rows.size).isEqualTo(1) + assertThat(content.rows[0].isComplete).isEqualTo(true) + } + } + + @Test + fun `continue advances to InviteFriend`() = runTest { + val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.PetIds)) } + val repository = FakeOnboardingRepository() + val sessionStore = OnboardingSessionStore(repository) + val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) + val presenter = OnboardingPetIdPresenter(sessionStore, navigator) + + presenter.test(OnboardingPetIdUiState.Loading) { + skipItems(1) + repository.onboardingDataResponses.add( + testOnboardingData( + contracts = listOf( + OnboardingContract( + id = "contract-1", + displayName = "Home Insurance", + exposureName = "Bellmansgatan 19A", + typeOfContract = "SE_APARTMENT_RENT", + missingCoInsuredCount = 0, + isMissingPetId = true, + ), + ), + ).right(), + ) + awaitItem() + sendEvent(OnboardingPetIdEvent.Continue) + runCurrent() + assertThat(backstack.entries.last()).isEqualTo(OnboardingStepKey(OnboardingStepId.InviteFriend)) + } + } +} From 9e4e3758ee9e61885c731ec525709f52aad8b66b Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 21 Jul 2026 17:33:13 +0200 Subject: [PATCH 022/119] Add onboarding invite a friend step --- .../ui/invite/OnboardingInviteDestination.kt | 177 ++++++++++++++++++ .../invite/OnboardingInvitePresenterTest.kt | 88 +++++++++ 2 files changed, 265 insertions(+) create mode 100644 app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt create mode 100644 app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInvitePresenterTest.kt diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt new file mode 100644 index 0000000000..a3385c1dd9 --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt @@ -0,0 +1,177 @@ +package com.hedvig.android.feature.onboarding.ui.invite + +import android.content.Context +import android.content.Intent +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.hedvig.android.core.common.di.ActivityRetainedScope +import com.hedvig.android.core.common.di.HedvigViewModel +import com.hedvig.android.design.system.hedvig.HedvigErrorSection +import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgressDebounced +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore +import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator +import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId +import com.hedvig.android.feature.onboarding.ui.OnboardingProgress +import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons +import com.hedvig.android.feature.onboarding.ui.OnboardingStepHeader +import com.hedvig.android.feature.onboarding.ui.OnboardingStepScaffold +import com.hedvig.android.feature.onboarding.ui.progressFor +import com.hedvig.android.molecule.public.MoleculePresenter +import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.molecule.public.MoleculeViewModel +import dev.zacsweers.metro.Inject +import kotlinx.coroutines.launch + +@Inject +@HedvigViewModel(ActivityRetainedScope::class) +internal class OnboardingInviteViewModel( + sessionStore: OnboardingSessionStore, + navigator: OnboardingNavigator, +) : MoleculeViewModel( + initialState = OnboardingInviteUiState.Loading, + presenter = OnboardingInvitePresenter(sessionStore, navigator), + ) + +internal class OnboardingInvitePresenter( + private val sessionStore: OnboardingSessionStore, + private val navigator: OnboardingNavigator, +) : MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present( + lastState: OnboardingInviteUiState, + ): OnboardingInviteUiState { + var currentState by remember { mutableStateOf(lastState) } + var loadIteration by remember { mutableIntStateOf(0) } + + LaunchedEffect(loadIteration) { + if (currentState is OnboardingInviteUiState.Content) return@LaunchedEffect + currentState = OnboardingInviteUiState.Loading + sessionStore.getOrFetchSession().fold( + ifLeft = { currentState = OnboardingInviteUiState.Error }, + ifRight = { session -> + val referral = session.data.referralInformation + currentState = if (referral == null) { + OnboardingInviteUiState.Error + } else { + OnboardingInviteUiState.Content( + progress = session.progressFor(OnboardingStepId.InviteFriend), + code = referral.code, + incentiveDisplay = "${referral.monthlyDiscountPerReferralAmount.toInt()} ${referral.currencyCode}", + ) + } + }, + ) + } + + CollectEvents { event -> + when (event) { + OnboardingInviteEvent.Retry -> loadIteration++ + OnboardingInviteEvent.Close -> launch { navigator.exitOnboarding() } + OnboardingInviteEvent.Continue -> launch { navigator.continueFrom(OnboardingStepId.InviteFriend) } + } + } + + return currentState + } +} + +internal sealed interface OnboardingInviteUiState { + data object Loading : OnboardingInviteUiState + + data object Error : OnboardingInviteUiState + + data class Content( + val progress: OnboardingProgress, + val code: String, + val incentiveDisplay: String, + ) : OnboardingInviteUiState +} + +internal sealed interface OnboardingInviteEvent { + data object Retry : OnboardingInviteEvent + + data object Close : OnboardingInviteEvent + + data object Continue : OnboardingInviteEvent +} + +@Composable +internal fun OnboardingInviteDestination(viewModel: OnboardingInviteViewModel, navigateUp: () -> Unit) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + OnboardingStepScaffold( + progress = (uiState as? OnboardingInviteUiState.Content)?.progress, + showBackButton = true, + onBackClick = navigateUp, + onCloseClick = { viewModel.emit(OnboardingInviteEvent.Close) }, + ) { + when (val state = uiState) { + OnboardingInviteUiState.Loading -> { + HedvigFullScreenCenterAlignedProgressDebounced() + } + + OnboardingInviteUiState.Error -> { + HedvigErrorSection( + onButtonClick = { viewModel.emit(OnboardingInviteEvent.Retry) }, + ) + } + + is OnboardingInviteUiState.Content -> { + val content = state + Spacer(Modifier.height(8.dp)) + OnboardingStepHeader( + // TODO: Add "Invite a friend" / "Bjud in en vän" to Lokalise + title = "Invite a friend", + // TODO: Add the body copy below (and its Swedish translation) to Lokalise + description = "With Hedvig Forever, you get ${content.incentiveDisplay} off for every friend you invite", + ) + Spacer(Modifier.height(16.dp)) + HedvigText( + text = content.code, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + OnboardingStepButtons( + // TODO: Add "Continue" / "Fortsätt" to Lokalise + primaryText = "Continue", + onPrimaryClick = { viewModel.emit(OnboardingInviteEvent.Continue) }, + // TODO: Add "Invite a friend" / "Bjud in en vän" to Lokalise + secondaryText = "Invite a friend", + onSecondaryClick = { + // TODO: Add the share message to Lokalise; align copy with the Forever screen's + // REFERRAL_SMS_MESSAGE once product confirms. + context.showShareSheet( + title = "Invite a friend", + text = "With my code you get a discount at Hedvig. Use code ${content.code}.", + ) + }, + ) + } + } + } +} + +private fun Context.showShareSheet(title: String, text: String) { + startActivity( + Intent.createChooser( + Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, text) + type = "text/plain" + }, + title, + ), + ) +} diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInvitePresenterTest.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInvitePresenterTest.kt new file mode 100644 index 0000000000..72472f0055 --- /dev/null +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInvitePresenterTest.kt @@ -0,0 +1,88 @@ +package com.hedvig.android.feature.onboarding.ui.invite + +import androidx.compose.runtime.mutableStateListOf +import arrow.core.right +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import com.hedvig.android.feature.onboarding.FakeOnboardingRepository +import com.hedvig.android.feature.onboarding.data.CompleteOnboardingUseCase +import com.hedvig.android.feature.onboarding.data.OnboardingReferralInformation +import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore +import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator +import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId +import com.hedvig.android.feature.onboarding.navigation.OnboardingStepKey +import com.hedvig.android.feature.onboarding.testOnboardingData +import com.hedvig.android.logger.TestLogcatLoggingRule +import com.hedvig.android.molecule.test.test +import com.hedvig.android.navigation.common.HedvigNavKey +import com.hedvig.android.navigation.compose.Backstack +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +internal class OnboardingInvitePresenterTest { + @get:Rule + val testLogcatRule = TestLogcatLoggingRule() + + private class TestBackstack : Backstack { + override val entries: MutableList = mutableStateListOf() + } + + private class NoopCompleteOnboardingUseCase : CompleteOnboardingUseCase { + override suspend fun invoke() {} + } + + @Test + fun `content carries the referral code and incentive`() = runTest { + val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.InviteFriend)) } + val repository = FakeOnboardingRepository() + val sessionStore = OnboardingSessionStore(repository) + val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) + val presenter = OnboardingInvitePresenter(sessionStore, navigator) + + presenter.test(OnboardingInviteUiState.Loading) { + skipItems(1) + repository.onboardingDataResponses.add(testOnboardingData().right()) + val state = awaitItem() + assertThat(state).isInstanceOf() + val content = state as OnboardingInviteUiState.Content + assertThat(content.code).isEqualTo("CODE") + assertThat(content.incentiveDisplay).isEqualTo("10 SEK") + } + } + + @Test + fun `continue advances to connect payment`() = runTest { + val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.InviteFriend)) } + val repository = FakeOnboardingRepository() + val sessionStore = OnboardingSessionStore(repository) + val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) + val presenter = OnboardingInvitePresenter(sessionStore, navigator) + + presenter.test(OnboardingInviteUiState.Loading) { + skipItems(1) + repository.onboardingDataResponses.add(testOnboardingData().right()) + assertThat(awaitItem()).isInstanceOf() + sendEvent(OnboardingInviteEvent.Continue) + runCurrent() + assertThat(backstack.entries.last()).isEqualTo(OnboardingStepKey(OnboardingStepId.ConnectPayment)) + } + } + + @Test + fun `step renders error state if the session has no referral information`() = runTest { + val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.InviteFriend)) } + val repository = FakeOnboardingRepository() + val sessionStore = OnboardingSessionStore(repository) + val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) + val presenter = OnboardingInvitePresenter(sessionStore, navigator) + + presenter.test(OnboardingInviteUiState.Loading) { + skipItems(1) + repository.onboardingDataResponses.add(testOnboardingData(referralInformation = null).right()) + assertThat(awaitItem()).isEqualTo(OnboardingInviteUiState.Error) + } + } +} From 8b075a741af61530d635ec6b85cc7f66b87b106a Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 21 Jul 2026 17:41:05 +0200 Subject: [PATCH 023/119] Add onboarding connect payment step --- .../navigation/OnboardingNavigator.kt | 6 + .../payment/OnboardingPaymentDestination.kt | 211 ++++++++++++++++++ .../payment/OnboardingPaymentPresenterTest.kt | 112 ++++++++++ 3 files changed, 329 insertions(+) create mode 100644 app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt create mode 100644 app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentPresenterTest.kt diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingNavigator.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingNavigator.kt index ce6c4118dc..493548f6a7 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingNavigator.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingNavigator.kt @@ -2,6 +2,7 @@ package com.hedvig.android.feature.onboarding.navigation import com.hedvig.android.core.common.di.ActivityRetainedScope import com.hedvig.android.data.coinsured.CoInsuredFlowType +import com.hedvig.android.feature.connect.payment.trustly.ui.TrustlyKey import com.hedvig.android.feature.editcoinsured.navigation.CoInsuredAddInfoKey import com.hedvig.android.feature.onboarding.data.CompleteOnboardingUseCase import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore @@ -56,6 +57,11 @@ internal class OnboardingNavigator( backstack.add(CoInsuredAddInfoKey(contractId, CoInsuredFlowType.CoInsured)) } + /** Pushes the Trustly connect-payment flow; it pops itself back here when done. */ + fun openConnectPayment() { + backstack.add(TrustlyKey) + } + /** Reserved for the top app bar back arrow only, per the navigation rule. */ fun navigateBack() { backstack.navigateUp() diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt new file mode 100644 index 0000000000..dc5a5bd711 --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt @@ -0,0 +1,211 @@ +package com.hedvig.android.feature.onboarding.ui.payment + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.hedvig.android.core.common.di.ActivityRetainedScope +import com.hedvig.android.core.common.di.HedvigViewModel +import com.hedvig.android.design.system.hedvig.HedvigErrorSection +import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgressDebounced +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.Icon +import com.hedvig.android.design.system.hedvig.icon.Checkmark +import com.hedvig.android.design.system.hedvig.icon.HedvigIcons +import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore +import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator +import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId +import com.hedvig.android.feature.onboarding.ui.OnboardingProgress +import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons +import com.hedvig.android.feature.onboarding.ui.OnboardingStepHeader +import com.hedvig.android.feature.onboarding.ui.OnboardingStepScaffold +import com.hedvig.android.feature.onboarding.ui.progressFor +import com.hedvig.android.molecule.public.MoleculePresenter +import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.molecule.public.MoleculeViewModel +import dev.zacsweers.metro.Inject +import kotlinx.coroutines.launch + +@Inject +@HedvigViewModel(ActivityRetainedScope::class) +internal class OnboardingPaymentViewModel( + sessionStore: OnboardingSessionStore, + navigator: OnboardingNavigator, +) : MoleculeViewModel( + initialState = OnboardingPaymentUiState.Loading, + presenter = OnboardingPaymentPresenter(sessionStore, navigator), + ) + +internal class OnboardingPaymentPresenter( + private val sessionStore: OnboardingSessionStore, + private val navigator: OnboardingNavigator, +) : MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present( + lastState: OnboardingPaymentUiState, + ): OnboardingPaymentUiState { + var currentState by remember { mutableStateOf(lastState) } + var loadIteration by remember { mutableIntStateOf(0) } + + LaunchedEffect(loadIteration) { + if (currentState is OnboardingPaymentUiState.Content) return@LaunchedEffect + currentState = OnboardingPaymentUiState.Loading + sessionStore.getOrFetchSession().fold( + ifLeft = { currentState = OnboardingPaymentUiState.Error }, + ifRight = { session -> + currentState = OnboardingPaymentUiState.Content( + progress = session.progressFor(OnboardingStepId.ConnectPayment), + isConnected = session.data.hasConnectedPayinMethod, + ) + }, + ) + } + + CollectEvents { event -> + when (event) { + OnboardingPaymentEvent.Retry -> loadIteration++ + + OnboardingPaymentEvent.Close -> launch { navigator.exitOnboarding() } + + OnboardingPaymentEvent.Refresh -> launch { + sessionStore.refreshData().onRight { refreshed -> + currentState = OnboardingPaymentUiState.Content( + progress = refreshed.progressFor(OnboardingStepId.ConnectPayment), + isConnected = refreshed.data.hasConnectedPayinMethod, + ) + } + } + + OnboardingPaymentEvent.ConnectPayment -> navigator.openConnectPayment() + + OnboardingPaymentEvent.Continue -> launch { navigator.continueFrom(OnboardingStepId.ConnectPayment) } + } + } + + return currentState + } +} + +internal sealed interface OnboardingPaymentUiState { + data object Loading : OnboardingPaymentUiState + + data object Error : OnboardingPaymentUiState + + data class Content( + val progress: OnboardingProgress, + val isConnected: Boolean, + ) : OnboardingPaymentUiState +} + +internal sealed interface OnboardingPaymentEvent { + data object Retry : OnboardingPaymentEvent + + data object Close : OnboardingPaymentEvent + + data object Refresh : OnboardingPaymentEvent + + data object ConnectPayment : OnboardingPaymentEvent + + data object Continue : OnboardingPaymentEvent +} + +@Composable +internal fun OnboardingPaymentDestination(viewModel: OnboardingPaymentViewModel, navigateUp: () -> Unit) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + var hasResumedOnce by rememberSaveable { mutableStateOf(false) } + LifecycleResumeEffect(Unit) { + if (hasResumedOnce) { + viewModel.emit(OnboardingPaymentEvent.Refresh) + } else { + hasResumedOnce = true + } + onPauseOrDispose {} + } + + OnboardingStepScaffold( + progress = (uiState as? OnboardingPaymentUiState.Content)?.progress, + showBackButton = true, + onBackClick = navigateUp, + onCloseClick = { viewModel.emit(OnboardingPaymentEvent.Close) }, + ) { + when (val content = uiState) { + OnboardingPaymentUiState.Loading -> { + HedvigFullScreenCenterAlignedProgressDebounced() + } + + OnboardingPaymentUiState.Error -> { + HedvigErrorSection( + onButtonClick = { viewModel.emit(OnboardingPaymentEvent.Retry) }, + ) + } + + is OnboardingPaymentUiState.Content -> { + Spacer(Modifier.height(8.dp)) + if (!content.isConnected) { + OnboardingStepHeader( + // TODO: Add "Connect payment" / "Koppla betalning" to Lokalise + title = "Connect payment", + // TODO: Add "Add a payment method to activate your insurance" / + // "Lägg till en betalmetod för att aktivera din försäkring" to Lokalise + description = "Add a payment method to activate your insurance", + ) + Spacer(Modifier.weight(1f)) + // TODO: Add "Adding a payment method is required to keep your insurance active" / + // "Du behöver lägga till en betalmetod för att hålla din försäkring aktiv" to Lokalise + HedvigText( + text = "Adding a payment method is required to keep your insurance active", + color = HedvigTheme.colorScheme.textSecondary, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(horizontal = 32.dp), + ) + OnboardingStepButtons( + // TODO: Add "Connect payment" / "Koppla betalning" to Lokalise + primaryText = "Connect payment", + onPrimaryClick = { viewModel.emit(OnboardingPaymentEvent.ConnectPayment) }, + ) + } else { + OnboardingStepHeader( + // TODO: Add "Connect payment" / "Koppla betalning" to Lokalise + title = "Connect payment", + // TODO: Add "Your payment method is connected" / "Din betalmetod är kopplad" to Lokalise + description = "Your payment method is connected", + ) + Spacer(Modifier.weight(1f)) + Icon( + imageVector = HedvigIcons.Checkmark, + contentDescription = null, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + Spacer(Modifier.height(16.dp)) + // TODO: Add "You can switch accounts later in settings" / + // "Du kan byta konto senare i inställningar" to Lokalise + HedvigText( + text = "You can switch accounts later in settings", + color = HedvigTheme.colorScheme.textSecondary, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + OnboardingStepButtons( + // TODO: Add "Continue" / "Fortsätt" to Lokalise + primaryText = "Continue", + onPrimaryClick = { viewModel.emit(OnboardingPaymentEvent.Continue) }, + ) + } + } + } + } +} diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentPresenterTest.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentPresenterTest.kt new file mode 100644 index 0000000000..08de4510b5 --- /dev/null +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentPresenterTest.kt @@ -0,0 +1,112 @@ +package com.hedvig.android.feature.onboarding.ui.payment + +import androidx.compose.runtime.mutableStateListOf +import arrow.core.right +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import com.hedvig.android.feature.connect.payment.trustly.ui.TrustlyKey +import com.hedvig.android.feature.onboarding.FakeOnboardingRepository +import com.hedvig.android.feature.onboarding.data.CompleteOnboardingUseCase +import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore +import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator +import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId +import com.hedvig.android.feature.onboarding.navigation.OnboardingStepKey +import com.hedvig.android.feature.onboarding.testOnboardingData +import com.hedvig.android.logger.TestLogcatLoggingRule +import com.hedvig.android.molecule.test.test +import com.hedvig.android.navigation.common.HedvigNavKey +import com.hedvig.android.navigation.compose.Backstack +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +internal class OnboardingPaymentPresenterTest { + @get:Rule + val testLogcatRule = TestLogcatLoggingRule() + + private class TestBackstack : Backstack { + override val entries: MutableList = mutableStateListOf() + } + + private class NoopCompleteOnboardingUseCase : CompleteOnboardingUseCase { + override suspend fun invoke() {} + } + + @Test + fun `not connected content when payin methods are missing`() = runTest { + val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.ConnectPayment)) } + val repository = FakeOnboardingRepository() + val sessionStore = OnboardingSessionStore(repository) + val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) + val presenter = OnboardingPaymentPresenter(sessionStore, navigator) + + presenter.test(OnboardingPaymentUiState.Loading) { + skipItems(1) + repository.onboardingDataResponses.add(testOnboardingData(hasConnectedPayinMethod = false).right()) + val state = awaitItem() + assertThat(state).isInstanceOf() + val content = state as OnboardingPaymentUiState.Content + assertThat(content.isConnected).isEqualTo(false) + } + } + + @Test + fun `connect payment pushes the trustly flow`() = runTest { + val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.ConnectPayment)) } + val repository = FakeOnboardingRepository() + val sessionStore = OnboardingSessionStore(repository) + val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) + val presenter = OnboardingPaymentPresenter(sessionStore, navigator) + + presenter.test(OnboardingPaymentUiState.Loading) { + skipItems(1) + repository.onboardingDataResponses.add(testOnboardingData().right()) + awaitItem() + sendEvent(OnboardingPaymentEvent.ConnectPayment) + runCurrent() + assertThat(backstack.entries.last()).isEqualTo(TrustlyKey) + } + } + + @Test + fun `refresh after returning flips to connected`() = runTest { + val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.ConnectPayment)) } + val repository = FakeOnboardingRepository() + val sessionStore = OnboardingSessionStore(repository) + val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) + val presenter = OnboardingPaymentPresenter(sessionStore, navigator) + + presenter.test(OnboardingPaymentUiState.Loading) { + skipItems(1) + repository.onboardingDataResponses.add(testOnboardingData().right()) + awaitItem() + sendEvent(OnboardingPaymentEvent.Refresh) + runCurrent() + repository.onboardingDataResponses.add(testOnboardingData(hasConnectedPayinMethod = true).right()) + val state = awaitItem() + assertThat(state).isInstanceOf() + val content = state as OnboardingPaymentUiState.Content + assertThat(content.isConnected).isEqualTo(true) + } + } + + @Test + fun `continue advances to BundleDiscount`() = runTest { + val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.ConnectPayment)) } + val repository = FakeOnboardingRepository() + val sessionStore = OnboardingSessionStore(repository) + val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) + val presenter = OnboardingPaymentPresenter(sessionStore, navigator) + + presenter.test(OnboardingPaymentUiState.Loading) { + skipItems(1) + repository.onboardingDataResponses.add(testOnboardingData().right()) + assertThat(awaitItem()).isInstanceOf() + sendEvent(OnboardingPaymentEvent.Continue) + runCurrent() + assertThat(backstack.entries.last()).isEqualTo(OnboardingStepKey(OnboardingStepId.BundleDiscount)) + } + } +} From 530cd7e3fb77004883df47b270f5e210940f46f7 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 21 Jul 2026 17:46:44 +0200 Subject: [PATCH 024/119] Add onboarding bundle discount step --- .../ui/bundle/OnboardingBundleDestination.kt | 168 ++++++++++++++++++ .../bundle/OnboardingBundlePresenterTest.kt | 92 ++++++++++ 2 files changed, 260 insertions(+) create mode 100644 app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt create mode 100644 app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundlePresenterTest.kt diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt new file mode 100644 index 0000000000..d85d3e04c4 --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt @@ -0,0 +1,168 @@ +package com.hedvig.android.feature.onboarding.ui.bundle + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.hedvig.android.core.common.di.ActivityRetainedScope +import com.hedvig.android.core.common.di.HedvigViewModel +import com.hedvig.android.design.system.hedvig.HedvigErrorSection +import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgressDebounced +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTextButton +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.feature.onboarding.data.OnboardingCrossSell +import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore +import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator +import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId +import com.hedvig.android.feature.onboarding.ui.OnboardingProgress +import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons +import com.hedvig.android.feature.onboarding.ui.OnboardingStepHeader +import com.hedvig.android.feature.onboarding.ui.OnboardingStepScaffold +import com.hedvig.android.feature.onboarding.ui.progressFor +import com.hedvig.android.molecule.public.MoleculePresenter +import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.molecule.public.MoleculeViewModel +import dev.zacsweers.metro.Inject +import kotlinx.coroutines.launch + +@Inject +@HedvigViewModel(ActivityRetainedScope::class) +internal class OnboardingBundleViewModel( + sessionStore: OnboardingSessionStore, + navigator: OnboardingNavigator, +) : MoleculeViewModel( + initialState = OnboardingBundleUiState.Loading, + presenter = OnboardingBundlePresenter(sessionStore, navigator), + ) + +internal class OnboardingBundlePresenter( + private val sessionStore: OnboardingSessionStore, + private val navigator: OnboardingNavigator, +) : MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present( + lastState: OnboardingBundleUiState, + ): OnboardingBundleUiState { + var currentState by remember { mutableStateOf(lastState) } + var loadIteration by remember { mutableIntStateOf(0) } + + LaunchedEffect(loadIteration) { + if (currentState is OnboardingBundleUiState.Content) return@LaunchedEffect + currentState = OnboardingBundleUiState.Loading + sessionStore.getOrFetchSession().fold( + ifLeft = { currentState = OnboardingBundleUiState.Error }, + ifRight = { session -> + currentState = OnboardingBundleUiState.Content( + progress = session.progressFor(OnboardingStepId.BundleDiscount), + crossSells = session.data.crossSells, + ) + }, + ) + } + + CollectEvents { event -> + when (event) { + OnboardingBundleEvent.Retry -> loadIteration++ + OnboardingBundleEvent.Close -> launch { navigator.exitOnboarding() } + OnboardingBundleEvent.ContinueToApp -> launch { navigator.continueFrom(OnboardingStepId.BundleDiscount) } + } + } + + return currentState + } +} + +internal sealed interface OnboardingBundleUiState { + data object Loading : OnboardingBundleUiState + + data object Error : OnboardingBundleUiState + + data class Content( + val progress: OnboardingProgress, + val crossSells: List, + ) : OnboardingBundleUiState +} + +internal sealed interface OnboardingBundleEvent { + data object Retry : OnboardingBundleEvent + + data object Close : OnboardingBundleEvent + + data object ContinueToApp : OnboardingBundleEvent +} + +@Composable +internal fun OnboardingBundleDestination( + viewModel: OnboardingBundleViewModel, + navigateUp: () -> Unit, + openUrl: (String) -> Unit, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + OnboardingStepScaffold( + progress = (uiState as? OnboardingBundleUiState.Content)?.progress, + showBackButton = true, + onBackClick = navigateUp, + onCloseClick = { viewModel.emit(OnboardingBundleEvent.Close) }, + ) { + when (val state = uiState) { + OnboardingBundleUiState.Loading -> { + HedvigFullScreenCenterAlignedProgressDebounced() + } + + OnboardingBundleUiState.Error -> { + HedvigErrorSection( + onButtonClick = { viewModel.emit(OnboardingBundleEvent.Retry) }, + ) + } + + is OnboardingBundleUiState.Content -> { + val content = state + Spacer(Modifier.height(8.dp)) + OnboardingStepHeader( + // TODO: Add "Get bundle discount" / "Få paketrabatt" to Lokalise + title = "Get bundle discount", + // TODO: Add "You get a bundle discount when you have two or more insurances with us" / "Du får paketrabatt när du har två eller fler försäkringar hos oss" to Lokalise + description = "You get a bundle discount when you have two or more insurances with us", + ) + Spacer(Modifier.height(16.dp)) + for (crossSell in content.crossSells) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Column(Modifier.weight(1f)) { + HedvigText(crossSell.title) + HedvigText(crossSell.description, color = HedvigTheme.colorScheme.textSecondary) + } + HedvigTextButton( + // TODO: Add "See price" / "Se pris" to Lokalise + text = "See price", + onClick = { openUrl(crossSell.storeUrl) }, + ) + } + } + OnboardingStepButtons( + // TODO: Add "Continue to app" / "Fortsätt till appen" to Lokalise + primaryText = "Continue to app", + onPrimaryClick = { viewModel.emit(OnboardingBundleEvent.ContinueToApp) }, + ) + } + } + } +} diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundlePresenterTest.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundlePresenterTest.kt new file mode 100644 index 0000000000..3f18dfcadd --- /dev/null +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundlePresenterTest.kt @@ -0,0 +1,92 @@ +package com.hedvig.android.feature.onboarding.ui.bundle + +import androidx.compose.runtime.mutableStateListOf +import arrow.core.right +import assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import assertk.assertions.isTrue +import com.hedvig.android.feature.onboarding.FakeOnboardingRepository +import com.hedvig.android.feature.onboarding.data.CompleteOnboardingUseCase +import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore +import com.hedvig.android.feature.onboarding.navigation.OnboardingKey +import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator +import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId +import com.hedvig.android.feature.onboarding.navigation.OnboardingStepKey +import com.hedvig.android.feature.onboarding.testOnboardingData +import com.hedvig.android.logger.TestLogcatLoggingRule +import com.hedvig.android.molecule.test.test +import com.hedvig.android.navigation.common.HedvigNavKey +import com.hedvig.android.navigation.compose.Backstack +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +internal class OnboardingBundlePresenterTest { + @get:Rule + val testLogcatRule = TestLogcatLoggingRule() + + private class TestBackstack : Backstack { + override val entries: MutableList = mutableStateListOf() + } + + private class NoopCompleteOnboardingUseCase : CompleteOnboardingUseCase { + override suspend fun invoke() {} + } + + private class FakeCompleteOnboardingUseCase : CompleteOnboardingUseCase { + var invoked: Boolean = false + + override suspend fun invoke() { + invoked = true + } + } + + @Test + fun `content lists the cross sells`() = runTest { + val backstack = TestBackstack().apply { + entries.add(OnboardingStepKey(OnboardingStepId.BundleDiscount)) + } + val repository = FakeOnboardingRepository() + val sessionStore = OnboardingSessionStore(repository) + val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) + val presenter = OnboardingBundlePresenter(sessionStore, navigator) + + presenter.test(OnboardingBundleUiState.Loading) { + skipItems(1) + repository.onboardingDataResponses.add(testOnboardingData().right()) + val state = awaitItem() + assertThat(state).isInstanceOf() + val content = state as OnboardingBundleUiState.Content + assertThat(content.crossSells.single().title).isEqualTo("Pet") + } + } + + @Test + fun `continue to app completes onboarding`() = runTest { + val completeOnboarding = FakeCompleteOnboardingUseCase() + val backstack = TestBackstack().apply { + entries.add(NonOnboardingKey) + entries.add(OnboardingKey) + entries.add(OnboardingStepKey(OnboardingStepId.BundleDiscount)) + } + val repository = FakeOnboardingRepository() + val sessionStore = OnboardingSessionStore(repository) + val navigator = OnboardingNavigator(backstack, sessionStore, completeOnboarding) + val presenter = OnboardingBundlePresenter(sessionStore, navigator) + + presenter.test(OnboardingBundleUiState.Loading) { + skipItems(1) + repository.onboardingDataResponses.add(testOnboardingData().right()) + assertThat(awaitItem()).isInstanceOf() + sendEvent(OnboardingBundleEvent.ContinueToApp) + runCurrent() + assertThat(completeOnboarding.invoked).isTrue() + assertThat(backstack.entries).containsExactly(NonOnboardingKey) + } + } +} + +private data object NonOnboardingKey : HedvigNavKey From 007d87e721eaf34e2e0af840f3c9f6b1ed219c4d Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 21 Jul 2026 18:11:17 +0200 Subject: [PATCH 025/119] Register onboarding entries and gate the flow after login --- app/app/build.gradle.kts | 1 + .../com/hedvig/android/app/MainActivity.kt | 1 + .../android/app/di/ActivityRetainedGraph.kt | 3 + .../app/navigation/HedvigEntryProvider.kt | 19 +++++ .../app/navigation/NavRetainedViewModel.kt | 3 + .../com/hedvig/android/app/ui/HedvigApp.kt | 42 ++++++++++ .../feature/onboarding/gate/OnboardingGate.kt | 42 ++++++++++ .../navigation/OnboardingEntries.kt | 80 +++++++++++++++++++ 8 files changed, 191 insertions(+) create mode 100644 app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/gate/OnboardingGate.kt create mode 100644 app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingEntries.kt diff --git a/app/app/build.gradle.kts b/app/app/build.gradle.kts index 5c11745bc3..70841f84b7 100644 --- a/app/app/build.gradle.kts +++ b/app/app/build.gradle.kts @@ -207,6 +207,7 @@ dependencies { implementation(projects.featureLogin) implementation(projects.featureMovingflow) implementation(projects.featureMovingflowNavigation) + implementation(projects.featureOnboarding) implementation(projects.featureRemoveAddons) implementation(projects.featurePayoutAccount) 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 e579f80631..9052cf094e 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 @@ -274,6 +274,7 @@ class MainActivity : AppCompatActivity() { currentDestinationHolder = currentDestinationHolder, eventTrackingClient = eventTrackingClient, screenParameterExtractor = screenParameterExtractor, + onboardingGate = navRetainedViewModel.onboardingGate, ) } } diff --git a/app/app/src/main/kotlin/com/hedvig/android/app/di/ActivityRetainedGraph.kt b/app/app/src/main/kotlin/com/hedvig/android/app/di/ActivityRetainedGraph.kt index 85a02fd9a4..0fe301233f 100644 --- a/app/app/src/main/kotlin/com/hedvig/android/app/di/ActivityRetainedGraph.kt +++ b/app/app/src/main/kotlin/com/hedvig/android/app/di/ActivityRetainedGraph.kt @@ -4,6 +4,7 @@ import com.hedvig.android.app.navigation.BackstackController import com.hedvig.android.app.navigation.SessionReconciler import com.hedvig.android.core.common.di.ActivityRetainedScope import com.hedvig.android.core.common.di.AppScope +import com.hedvig.android.feature.onboarding.gate.OnboardingGate import com.hedvig.android.navigation.compose.Backstack import dev.zacsweers.metro.ContributesTo import dev.zacsweers.metro.GraphExtension @@ -28,6 +29,8 @@ import dev.zacsweers.metrox.viewmodel.MetroViewModelMultibindings internal interface ActivityRetainedGraph : MetroViewModelMultibindings { val sessionReconciler: SessionReconciler + val onboardingGate: OnboardingGate + @Provides fun bindBackstack(controller: BackstackController): Backstack = controller 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 77d48d8a39..043f924bb3 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 @@ -47,6 +47,7 @@ import com.hedvig.android.feature.insurances.navigation.insuranceEntries import com.hedvig.android.feature.login.navigation.loginEntries import com.hedvig.android.feature.movingflow.SelectContractForMovingKey import com.hedvig.android.feature.movingflow.movingFlowEntries +import com.hedvig.android.feature.onboarding.navigation.onboardingEntries import com.hedvig.android.feature.payments.navigation.paymentsEntries import com.hedvig.android.feature.payoutaccount.navigation.PayoutAccountKey import com.hedvig.android.feature.payoutaccount.navigation.payoutAccountEntries @@ -56,6 +57,7 @@ import com.hedvig.android.feature.terminateinsurance.navigation.TerminateInsuran import com.hedvig.android.feature.terminateinsurance.navigation.terminateInsuranceEntries import com.hedvig.android.feature.travelcertificate.navigation.TravelCertificateKey import com.hedvig.android.feature.travelcertificate.navigation.travelCertificateEntries +import com.hedvig.android.language.Language import com.hedvig.android.language.LanguageService import com.hedvig.android.logger.logcat import com.hedvig.android.navigation.activity.ExternalNavigator @@ -165,9 +167,19 @@ internal fun EntryProviderScope.hedvigEntryProvider( externalNavigator = externalNavigator, navigateToNewConversation = navigateToNewConversation, navigateToInbox = navigateToInbox, + openPrivacyPolicy = { openUrl(privacyPolicyUrl(languageService.getLanguage())) }, ) } +/** + * The public privacy-policy page, per language. Mirrors the links used by the profile "about app" + * screen; kept here so `:app` owns the URL rather than `feature-onboarding`. + */ +private fun privacyPolicyUrl(language: Language): String = when (language) { + Language.SV_SE -> "https://www.hedvig.com/se/hedvig/personuppgifter" + Language.EN_SE -> "https://www.hedvig.com/se-en/hedvig/privacy-policy" +} + private fun EntryProviderScope.addLoginEntries( backstack: BackstackController, hedvigBuildConstants: HedvigBuildConstants, @@ -496,6 +508,7 @@ private fun EntryProviderScope.addSharedFlowEntries( externalNavigator: ExternalNavigator, navigateToNewConversation: () -> Unit, navigateToInbox: () -> Unit, + openPrivacyPolicy: () -> Unit, ) { addonPurchaseEntries( backstack = backstack, @@ -531,4 +544,10 @@ private fun EntryProviderScope.addSharedFlowEntries( ) imageViewerEntries(backstack, imageLoader) removeAddonsEntries(backstack = backstack) + onboardingEntries( + backstack = backstack, + openUrl = openUrl, + openPrivacyPolicy = openPrivacyPolicy, + navigateToChipId = { contractId -> backstack.add(ChipIdKey(contractId)) }, + ) } diff --git a/app/app/src/main/kotlin/com/hedvig/android/app/navigation/NavRetainedViewModel.kt b/app/app/src/main/kotlin/com/hedvig/android/app/navigation/NavRetainedViewModel.kt index 16fd9fe8c7..c79cb3a820 100644 --- a/app/app/src/main/kotlin/com/hedvig/android/app/navigation/NavRetainedViewModel.kt +++ b/app/app/src/main/kotlin/com/hedvig/android/app/navigation/NavRetainedViewModel.kt @@ -7,6 +7,7 @@ import androidx.lifecycle.ViewModel import com.hedvig.android.app.di.ActivityRetainedGraph import com.hedvig.android.app.di.AppGraph import com.hedvig.android.app.di.MergedMetroViewModelFactory +import com.hedvig.android.feature.onboarding.gate.OnboardingGate import dev.zacsweers.metro.asContribution import dev.zacsweers.metrox.viewmodel.MetroViewModelFactory @@ -39,5 +40,7 @@ internal class NavRetainedViewModel(appGraph: AppGraph, isOwnTask: Boolean) : Vi val sessionReconciler: SessionReconciler = activityGraph.sessionReconciler + val onboardingGate: OnboardingGate = activityGraph.onboardingGate + val viewModelFactory: MetroViewModelFactory = MergedMetroViewModelFactory(appGraph, activityGraph) } 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 2d8b39d678..509e9b1f26 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 @@ -68,6 +68,9 @@ import com.hedvig.android.design.system.hedvig.Surface import com.hedvig.android.design.system.hedvig.motion.MotionDefaults import com.hedvig.android.design.system.hedvig.rememberGlobalSnackBarState import com.hedvig.android.feature.cross.sell.sheet.CrossSellSheet +import com.hedvig.android.feature.home.home.navigation.HomeKey +import com.hedvig.android.feature.onboarding.gate.OnboardingGate +import com.hedvig.android.feature.onboarding.navigation.OnboardingKey import com.hedvig.android.featureflags.FeatureManager import com.hedvig.android.language.LanguageService import com.hedvig.android.logger.logcat @@ -75,6 +78,7 @@ import com.hedvig.android.navigation.activity.ExternalNavigator import com.hedvig.android.navigation.common.HedvigNavKey import com.hedvig.android.navigation.compose.BottomSheetSceneStrategy import com.hedvig.android.navigation.compose.HedvigDeepLinkMatcher +import com.hedvig.android.navigation.compose.add import com.hedvig.android.navigation.compose.entryDecorators import com.hedvig.android.notification.badge.data.payment.MissedPaymentNotificationService import com.hedvig.android.ui.force.upgrade.ForceUpgradeBlockingScreen @@ -115,6 +119,7 @@ internal fun HedvigApp( currentDestinationHolder: CurrentDestinationHolder, eventTrackingClient: EventTrackingClient, screenParameterExtractor: ScreenParameterExtractor, + onboardingGate: OnboardingGate, ) { ReportCurrentDestinationEffect(backstackController, currentDestinationHolder) TrackScreenViewEffect(backstackController, eventTrackingClient, screenParameterExtractor) @@ -139,6 +144,11 @@ internal fun HedvigApp( waitUntilAppReviewDialogShouldBeOpenedUseCase, androidAppHost::tryShowAppStoreReviewDialog, ) + TryShowOnboardingEffect( + authTokenService = authTokenService, + onboardingGate = onboardingGate, + backstackController = backstackController, + ) val scope = rememberCoroutineScope() val context = LocalContext.current val deepLinkFirstUriHandler = remember(deepLinkMatcher, backstackController, context) { @@ -368,3 +378,35 @@ private fun TryShowAppStoreReviewDialogEffect( } } } + +/** + * Once logged in, asks the [OnboardingGate] whether the onboarding flow should be shown and, if so, + * pushes [OnboardingKey] on top of an idle Home root. Guards: never while onboarding is already up + * (the root [OnboardingKey] is always present while any step is showing, since steps are pushed on + * top of it), and only over Home with no pending deep link, so a deep-linked or in-progress flow is + * never interrupted. + */ +@Composable +private fun TryShowOnboardingEffect( + authTokenService: AuthTokenService, + onboardingGate: OnboardingGate, + backstackController: BackstackController, +) { + val lifecycle = LocalLifecycleOwner.current.lifecycle + LaunchedEffect(lifecycle) { + lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { + authTokenService.authStatus.first { it is AuthStatus.LoggedIn } + // Already showing (e.g. resumed mid-flow): nothing to do this pass. + if (backstackController.entries.any { it is OnboardingKey }) { + return@repeatOnLifecycle + } + if (!onboardingGate.shouldShowOnboarding()) return@repeatOnLifecycle + // Only interrupt an idle Home root, never a deep-linked or in-progress flow. + snapshotFlow { backstackController.currentDestination to backstackController.pendingDeepLink } + .first { (currentDestination, pendingDeepLink) -> + currentDestination is HomeKey && pendingDeepLink == null + } + backstackController.add(OnboardingKey) + } + } +} diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/gate/OnboardingGate.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/gate/OnboardingGate.kt new file mode 100644 index 0000000000..18be024eff --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/gate/OnboardingGate.kt @@ -0,0 +1,42 @@ +package com.hedvig.android.feature.onboarding.gate + +import com.hedvig.android.auth.MemberIdService +import com.hedvig.android.core.common.di.ActivityRetainedScope +import com.hedvig.android.feature.onboarding.data.OnboardingSeenStore +import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore +import com.hedvig.android.logger.LogPriority +import com.hedvig.android.logger.logcat +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import kotlinx.coroutines.flow.first + +/** + * Decides whether onboarding should be shown for the current member: never seen before, and the + * eager fetch succeeded (caching the session so the flow renders without further loading). On + * fetch failure this returns false WITHOUT marking seen, so the next app start retries. + */ +interface OnboardingGate { + suspend fun shouldShowOnboarding(): Boolean +} + +@ContributesBinding(ActivityRetainedScope::class) +@SingleIn(ActivityRetainedScope::class) +@Inject +internal class OnboardingGateImpl( + private val memberIdService: MemberIdService, + private val onboardingSeenStore: OnboardingSeenStore, + private val sessionStore: OnboardingSessionStore, +) : OnboardingGate { + override suspend fun shouldShowOnboarding(): Boolean { + val memberId = memberIdService.getMemberId().first() ?: return false + if (onboardingSeenStore.hasSeenOnboarding(memberId)) return false + return sessionStore.getOrFetchSession().fold( + ifLeft = { errorMessage -> + logcat(LogPriority.INFO) { "Onboarding data fetch failed, not showing onboarding: $errorMessage" } + false + }, + ifRight = { session -> session.path.isNotEmpty() }, + ) + } +} diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingEntries.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingEntries.kt new file mode 100644 index 0000000000..be16cb1051 --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingEntries.kt @@ -0,0 +1,80 @@ +package com.hedvig.android.feature.onboarding.navigation + +import androidx.navigation3.runtime.EntryProviderScope +import com.hedvig.android.feature.onboarding.ui.bundle.OnboardingBundleDestination +import com.hedvig.android.feature.onboarding.ui.bundle.OnboardingBundleViewModel +import com.hedvig.android.feature.onboarding.ui.coinsured.OnboardingCoInsuredDestination +import com.hedvig.android.feature.onboarding.ui.coinsured.OnboardingCoInsuredViewModel +import com.hedvig.android.feature.onboarding.ui.consent.OnboardingConsentDestination +import com.hedvig.android.feature.onboarding.ui.consent.OnboardingConsentViewModel +import com.hedvig.android.feature.onboarding.ui.invite.OnboardingInviteDestination +import com.hedvig.android.feature.onboarding.ui.invite.OnboardingInviteViewModel +import com.hedvig.android.feature.onboarding.ui.payment.OnboardingPaymentDestination +import com.hedvig.android.feature.onboarding.ui.payment.OnboardingPaymentViewModel +import com.hedvig.android.feature.onboarding.ui.petid.OnboardingPetIdDestination +import com.hedvig.android.feature.onboarding.ui.petid.OnboardingPetIdViewModel +import com.hedvig.android.feature.onboarding.ui.phone.OnboardingPhoneDestination +import com.hedvig.android.feature.onboarding.ui.phone.OnboardingPhoneViewModel +import com.hedvig.android.feature.onboarding.ui.theme.OnboardingThemeDestination +import com.hedvig.android.feature.onboarding.ui.theme.OnboardingThemeViewModel +import com.hedvig.android.feature.onboarding.ui.welcome.OnboardingWelcomeDestination +import com.hedvig.android.feature.onboarding.ui.welcome.OnboardingWelcomeViewModel +import com.hedvig.android.navigation.common.HedvigNavKey +import com.hedvig.android.navigation.compose.Backstack +import dev.zacsweers.metrox.viewmodel.metroViewModel + +fun EntryProviderScope.onboardingEntries( + backstack: Backstack, + openUrl: (String) -> Unit, + openPrivacyPolicy: () -> Unit, + navigateToChipId: (contractId: String) -> Unit, +) { + entry { + val viewModel: OnboardingWelcomeViewModel = metroViewModel() + OnboardingWelcomeDestination(viewModel) + } + + entry { key -> + when (key.stepId) { + OnboardingStepId.AnalyticsConsent -> { + val viewModel: OnboardingConsentViewModel = metroViewModel() + OnboardingConsentDestination(viewModel, backstack::navigateUp, openPrivacyPolicy) + } + + OnboardingStepId.PhoneNumber -> { + val viewModel: OnboardingPhoneViewModel = metroViewModel() + OnboardingPhoneDestination(viewModel, backstack::navigateUp) + } + + OnboardingStepId.Theme -> { + val viewModel: OnboardingThemeViewModel = metroViewModel() + OnboardingThemeDestination(viewModel, backstack::navigateUp) + } + + OnboardingStepId.CoInsured -> { + val viewModel: OnboardingCoInsuredViewModel = metroViewModel() + OnboardingCoInsuredDestination(viewModel, backstack::navigateUp) + } + + OnboardingStepId.PetIds -> { + val viewModel: OnboardingPetIdViewModel = metroViewModel() + OnboardingPetIdDestination(viewModel, backstack::navigateUp, navigateToChipId) + } + + OnboardingStepId.InviteFriend -> { + val viewModel: OnboardingInviteViewModel = metroViewModel() + OnboardingInviteDestination(viewModel, backstack::navigateUp) + } + + OnboardingStepId.ConnectPayment -> { + val viewModel: OnboardingPaymentViewModel = metroViewModel() + OnboardingPaymentDestination(viewModel, backstack::navigateUp) + } + + OnboardingStepId.BundleDiscount -> { + val viewModel: OnboardingBundleViewModel = metroViewModel() + OnboardingBundleDestination(viewModel, backstack::navigateUp, openUrl) + } + } + } +} From 594f931a5b404924f956162e7e9d3557a1b8be91 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 21 Jul 2026 18:36:40 +0200 Subject: [PATCH 026/119] Scope the onboarding session cache to the current member --- .../onboarding/data/OnboardingSessionStore.kt | 46 +++++++++++++++++-- .../onboarding/FakeOnboardingRepository.kt | 6 +++ .../data/OnboardingSessionStoreTest.kt | 46 +++++++++++++++++++ .../navigation/OnboardingNavigatorTest.kt | 5 +- .../bundle/OnboardingBundlePresenterTest.kt | 5 +- .../OnboardingCoInsuredPresenterTest.kt | 9 ++-- .../consent/OnboardingConsentPresenterTest.kt | 5 +- .../invite/OnboardingInvitePresenterTest.kt | 7 +-- .../payment/OnboardingPaymentPresenterTest.kt | 9 ++-- .../ui/petid/OnboardingPetIdPresenterTest.kt | 7 +-- .../ui/phone/OnboardingPhonePresenterTest.kt | 9 ++-- .../ui/theme/OnboardingThemePresenterTest.kt | 7 +-- .../welcome/OnboardingWelcomePresenterTest.kt | 7 +-- 13 files changed, 134 insertions(+), 34 deletions(-) create mode 100644 app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingSessionStoreTest.kt diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingSessionStore.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingSessionStore.kt index 46a39dc5c0..1bb7cbfe8d 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingSessionStore.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingSessionStore.kt @@ -3,23 +3,49 @@ package com.hedvig.android.feature.onboarding.data import arrow.core.Either import arrow.core.left import arrow.core.right +import com.hedvig.android.auth.MemberIdService import com.hedvig.android.core.common.ErrorMessage import com.hedvig.android.core.common.di.ActivityRetainedScope +import com.hedvig.android.core.common.di.AppScope import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId +import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +/** + * Resolves the current member id so a cached [OnboardingSession] can be scoped to the member it was + * fetched for. Exists as a seam so tests can fake member identity without an auth backend. + */ +internal fun interface OnboardingMemberIdProvider { + fun memberId(): Flow +} + +@ContributesBinding(AppScope::class) +@Inject +internal class OnboardingMemberIdProviderImpl( + private val memberIdService: MemberIdService, +) : OnboardingMemberIdProvider { + override fun memberId(): Flow = memberIdService.getMemberId() +} + /** * Holds the eagerly fetched onboarding data plus the computed step path for the lifetime of one * Activity. Keys stay tiny (see OnboardingStepKey); after process death this cache is empty and * the visible step re-fetches through [getOrFetchSession]. + * + * The cache is scoped to the member it was fetched for: this store is + * `@SingleIn(ActivityRetainedScope::class)` and survives a logout/login within one Activity, so a + * different member reaching [getOrFetchSession] must not receive the previous member's session. */ @SingleIn(ActivityRetainedScope::class) @Inject internal class OnboardingSessionStore( private val onboardingRepository: OnboardingRepository, + private val memberIdProvider: OnboardingMemberIdProvider, ) { private val mutex = Mutex() private var cachedSession: OnboardingSession? = null @@ -28,9 +54,15 @@ internal class OnboardingSessionStore( get() = cachedSession suspend fun getOrFetchSession(): Either = mutex.withLock { - cachedSession?.let { return@withLock it.right() } + val currentMemberId = memberIdProvider.memberId().first() + ?: return@withLock ErrorMessage("No member id for onboarding session").left() + cachedSession?.let { cached -> + if (cached.memberId == currentMemberId) return@withLock cached.right() + } + cachedSession = null onboardingRepository.getOnboardingData().map { data -> - OnboardingSession(data = data, path = buildOnboardingPath(data)).also { cachedSession = it } + OnboardingSession(memberId = currentMemberId, data = data, path = buildOnboardingPath(data)) + .also { cachedSession = it } } } @@ -39,8 +71,13 @@ internal class OnboardingSessionStore( * Trustly) but keeps the original path: the progress bar must not reshuffle mid-flow. */ suspend fun refreshData(): Either = mutex.withLock { - val existing = cachedSession - ?: return@withLock ErrorMessage("No onboarding session to refresh").left() + val currentMemberId = memberIdProvider.memberId().first() + ?: return@withLock ErrorMessage("No member id for onboarding session").left() + val existing = cachedSession?.takeIf { it.memberId == currentMemberId } + if (existing == null) { + cachedSession = null + return@withLock ErrorMessage("No onboarding session to refresh").left() + } onboardingRepository.getOnboardingData().map { data -> existing.copy(data = data).also { cachedSession = it } } @@ -48,6 +85,7 @@ internal class OnboardingSessionStore( } internal data class OnboardingSession( + val memberId: String, val data: OnboardingData, val path: List, ) diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/FakeOnboardingRepository.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/FakeOnboardingRepository.kt index e4a633e3cc..a2b3f13a2b 100644 --- a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/FakeOnboardingRepository.kt +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/FakeOnboardingRepository.kt @@ -6,8 +6,14 @@ import com.hedvig.android.core.common.ErrorMessage import com.hedvig.android.feature.onboarding.data.OnboardingContract import com.hedvig.android.feature.onboarding.data.OnboardingCrossSell import com.hedvig.android.feature.onboarding.data.OnboardingData +import com.hedvig.android.feature.onboarding.data.OnboardingMemberIdProvider import com.hedvig.android.feature.onboarding.data.OnboardingReferralInformation import com.hedvig.android.feature.onboarding.data.OnboardingRepository +import kotlinx.coroutines.flow.flowOf + +internal class FakeOnboardingMemberIdProvider(var memberId: String? = "test-member-id") : OnboardingMemberIdProvider { + override fun memberId() = flowOf(memberId) +} internal class FakeOnboardingRepository : OnboardingRepository { val onboardingDataResponses = Turbine>() diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingSessionStoreTest.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingSessionStoreTest.kt new file mode 100644 index 0000000000..7aaec064a6 --- /dev/null +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingSessionStoreTest.kt @@ -0,0 +1,46 @@ +package com.hedvig.android.feature.onboarding.data + +import arrow.core.right +import assertk.assertThat +import assertk.assertions.isEqualTo +import com.hedvig.android.feature.onboarding.FakeOnboardingMemberIdProvider +import com.hedvig.android.feature.onboarding.FakeOnboardingRepository +import com.hedvig.android.feature.onboarding.testOnboardingData +import kotlinx.coroutines.test.runTest +import org.junit.Test + +internal class OnboardingSessionStoreTest { + @Test + fun `same member reuses the cached session without refetching`() = runTest { + val repository = FakeOnboardingRepository() + val store = OnboardingSessionStore(repository, FakeOnboardingMemberIdProvider("member-a")) + + repository.onboardingDataResponses.add(testOnboardingData(phoneNumber = "111").right()) + val first = store.getOrFetchSession() + // No second response is fed; a refetch here would suspend forever on the Turbine. + val second = store.getOrFetchSession() + + assertThat(first).isEqualTo(second) + assertThat(second.getOrNull()?.data?.phoneNumber).isEqualTo("111") + repository.onboardingDataResponses.expectNoEvents() + } + + @Test + fun `a member change discards the cache and fetches fresh`() = runTest { + val repository = FakeOnboardingRepository() + val memberIdProvider = FakeOnboardingMemberIdProvider("member-a") + val store = OnboardingSessionStore(repository, memberIdProvider) + + repository.onboardingDataResponses.add(testOnboardingData(phoneNumber = "111").right()) + val memberASession = store.getOrFetchSession() + assertThat(memberASession.getOrNull()?.memberId).isEqualTo("member-a") + assertThat(memberASession.getOrNull()?.data?.phoneNumber).isEqualTo("111") + + memberIdProvider.memberId = "member-b" + repository.onboardingDataResponses.add(testOnboardingData(phoneNumber = "222").right()) + val memberBSession = store.getOrFetchSession() + + assertThat(memberBSession.getOrNull()?.memberId).isEqualTo("member-b") + assertThat(memberBSession.getOrNull()?.data?.phoneNumber).isEqualTo("222") + } +} diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingNavigatorTest.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingNavigatorTest.kt index 927b5d539a..c517c8375c 100644 --- a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingNavigatorTest.kt +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingNavigatorTest.kt @@ -8,6 +8,7 @@ import assertk.assertions.isEmpty import assertk.assertions.isEqualTo import assertk.assertions.isFalse import assertk.assertions.isTrue +import com.hedvig.android.feature.onboarding.FakeOnboardingMemberIdProvider import com.hedvig.android.feature.onboarding.FakeOnboardingRepository import com.hedvig.android.feature.onboarding.data.CompleteOnboardingUseCase import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore @@ -36,7 +37,7 @@ internal class OnboardingNavigatorTest { } private suspend fun sessionStoreWithData(repository: FakeOnboardingRepository): OnboardingSessionStore { - val store = OnboardingSessionStore(repository) + val store = OnboardingSessionStore(repository, FakeOnboardingMemberIdProvider()) repository.onboardingDataResponses.add(testOnboardingData().right()) store.getOrFetchSession() return store @@ -109,7 +110,7 @@ internal class OnboardingNavigatorTest { val completeOnboarding = FakeCompleteOnboardingUseCase() val navigator = OnboardingNavigator( backstack, - OnboardingSessionStore(FakeOnboardingRepository()), + OnboardingSessionStore(FakeOnboardingRepository(), FakeOnboardingMemberIdProvider()), completeOnboarding, ) diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundlePresenterTest.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundlePresenterTest.kt index 3f18dfcadd..0f69e9f547 100644 --- a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundlePresenterTest.kt +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundlePresenterTest.kt @@ -7,6 +7,7 @@ import assertk.assertions.containsExactly import assertk.assertions.isEqualTo import assertk.assertions.isInstanceOf import assertk.assertions.isTrue +import com.hedvig.android.feature.onboarding.FakeOnboardingMemberIdProvider import com.hedvig.android.feature.onboarding.FakeOnboardingRepository import com.hedvig.android.feature.onboarding.data.CompleteOnboardingUseCase import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore @@ -50,7 +51,7 @@ internal class OnboardingBundlePresenterTest { entries.add(OnboardingStepKey(OnboardingStepId.BundleDiscount)) } val repository = FakeOnboardingRepository() - val sessionStore = OnboardingSessionStore(repository) + val sessionStore = OnboardingSessionStore(repository, FakeOnboardingMemberIdProvider()) val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) val presenter = OnboardingBundlePresenter(sessionStore, navigator) @@ -73,7 +74,7 @@ internal class OnboardingBundlePresenterTest { entries.add(OnboardingStepKey(OnboardingStepId.BundleDiscount)) } val repository = FakeOnboardingRepository() - val sessionStore = OnboardingSessionStore(repository) + val sessionStore = OnboardingSessionStore(repository, FakeOnboardingMemberIdProvider()) val navigator = OnboardingNavigator(backstack, sessionStore, completeOnboarding) val presenter = OnboardingBundlePresenter(sessionStore, navigator) diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredPresenterTest.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredPresenterTest.kt index 7f256e0e0b..df7c942910 100644 --- a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredPresenterTest.kt +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredPresenterTest.kt @@ -9,6 +9,7 @@ import assertk.assertions.isInstanceOf import com.hedvig.android.core.common.ErrorMessage import com.hedvig.android.data.coinsured.CoInsuredFlowType import com.hedvig.android.feature.editcoinsured.navigation.CoInsuredAddInfoKey +import com.hedvig.android.feature.onboarding.FakeOnboardingMemberIdProvider import com.hedvig.android.feature.onboarding.FakeOnboardingRepository import com.hedvig.android.feature.onboarding.data.CompleteOnboardingUseCase import com.hedvig.android.feature.onboarding.data.OnboardingContract @@ -42,7 +43,7 @@ internal class OnboardingCoInsuredPresenterTest { fun `rows are pinned from the contracts missing co-insured at load`() = runTest { val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.CoInsured)) } val repository = FakeOnboardingRepository() - val sessionStore = OnboardingSessionStore(repository) + val sessionStore = OnboardingSessionStore(repository, FakeOnboardingMemberIdProvider()) val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) val presenter = OnboardingCoInsuredPresenter(sessionStore, navigator) @@ -61,7 +62,7 @@ internal class OnboardingCoInsuredPresenterTest { fun `add navigates to the edit co-insured flow`() = runTest { val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.CoInsured)) } val repository = FakeOnboardingRepository() - val sessionStore = OnboardingSessionStore(repository) + val sessionStore = OnboardingSessionStore(repository, FakeOnboardingMemberIdProvider()) val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) val presenter = OnboardingCoInsuredPresenter(sessionStore, navigator) @@ -79,7 +80,7 @@ internal class OnboardingCoInsuredPresenterTest { fun `refresh marks completed rows instead of removing them`() = runTest { val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.CoInsured)) } val repository = FakeOnboardingRepository() - val sessionStore = OnboardingSessionStore(repository) + val sessionStore = OnboardingSessionStore(repository, FakeOnboardingMemberIdProvider()) val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) val presenter = OnboardingCoInsuredPresenter(sessionStore, navigator) @@ -115,7 +116,7 @@ internal class OnboardingCoInsuredPresenterTest { fun `continue advances to InviteFriend (PetIds skipped for default data)`() = runTest { val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.CoInsured)) } val repository = FakeOnboardingRepository() - val sessionStore = OnboardingSessionStore(repository) + val sessionStore = OnboardingSessionStore(repository, FakeOnboardingMemberIdProvider()) val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) val presenter = OnboardingCoInsuredPresenter(sessionStore, navigator) diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingConsentPresenterTest.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingConsentPresenterTest.kt index 137c98935d..4c63531b72 100644 --- a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingConsentPresenterTest.kt +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingConsentPresenterTest.kt @@ -6,6 +6,7 @@ import assertk.assertThat import assertk.assertions.isEqualTo import assertk.assertions.isInstanceOf import com.hedvig.android.data.settings.datastore.AnalyticsConsent +import com.hedvig.android.feature.onboarding.FakeOnboardingMemberIdProvider import com.hedvig.android.feature.onboarding.FakeOnboardingRepository import com.hedvig.android.feature.onboarding.FakeSettingsDataStore import com.hedvig.android.feature.onboarding.data.CompleteOnboardingUseCase @@ -39,7 +40,7 @@ internal class OnboardingConsentPresenterTest { fun `allow stores GRANTED and advances to the next step`() = runTest { val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.AnalyticsConsent)) } val repository = FakeOnboardingRepository() - val sessionStore = OnboardingSessionStore(repository) + val sessionStore = OnboardingSessionStore(repository, FakeOnboardingMemberIdProvider()) val settingsDataStore = FakeSettingsDataStore() val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) val presenter = OnboardingConsentPresenter(sessionStore, navigator, settingsDataStore) @@ -59,7 +60,7 @@ internal class OnboardingConsentPresenterTest { fun `deny stores DENIED and still advances`() = runTest { val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.AnalyticsConsent)) } val repository = FakeOnboardingRepository() - val sessionStore = OnboardingSessionStore(repository) + val sessionStore = OnboardingSessionStore(repository, FakeOnboardingMemberIdProvider()) val settingsDataStore = FakeSettingsDataStore() val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) val presenter = OnboardingConsentPresenter(sessionStore, navigator, settingsDataStore) diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInvitePresenterTest.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInvitePresenterTest.kt index 72472f0055..75393847b7 100644 --- a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInvitePresenterTest.kt +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInvitePresenterTest.kt @@ -5,6 +5,7 @@ import arrow.core.right import assertk.assertThat import assertk.assertions.isEqualTo import assertk.assertions.isInstanceOf +import com.hedvig.android.feature.onboarding.FakeOnboardingMemberIdProvider import com.hedvig.android.feature.onboarding.FakeOnboardingRepository import com.hedvig.android.feature.onboarding.data.CompleteOnboardingUseCase import com.hedvig.android.feature.onboarding.data.OnboardingReferralInformation @@ -38,7 +39,7 @@ internal class OnboardingInvitePresenterTest { fun `content carries the referral code and incentive`() = runTest { val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.InviteFriend)) } val repository = FakeOnboardingRepository() - val sessionStore = OnboardingSessionStore(repository) + val sessionStore = OnboardingSessionStore(repository, FakeOnboardingMemberIdProvider()) val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) val presenter = OnboardingInvitePresenter(sessionStore, navigator) @@ -57,7 +58,7 @@ internal class OnboardingInvitePresenterTest { fun `continue advances to connect payment`() = runTest { val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.InviteFriend)) } val repository = FakeOnboardingRepository() - val sessionStore = OnboardingSessionStore(repository) + val sessionStore = OnboardingSessionStore(repository, FakeOnboardingMemberIdProvider()) val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) val presenter = OnboardingInvitePresenter(sessionStore, navigator) @@ -75,7 +76,7 @@ internal class OnboardingInvitePresenterTest { fun `step renders error state if the session has no referral information`() = runTest { val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.InviteFriend)) } val repository = FakeOnboardingRepository() - val sessionStore = OnboardingSessionStore(repository) + val sessionStore = OnboardingSessionStore(repository, FakeOnboardingMemberIdProvider()) val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) val presenter = OnboardingInvitePresenter(sessionStore, navigator) diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentPresenterTest.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentPresenterTest.kt index 08de4510b5..1c0c46f351 100644 --- a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentPresenterTest.kt +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentPresenterTest.kt @@ -6,6 +6,7 @@ import assertk.assertThat import assertk.assertions.isEqualTo import assertk.assertions.isInstanceOf import com.hedvig.android.feature.connect.payment.trustly.ui.TrustlyKey +import com.hedvig.android.feature.onboarding.FakeOnboardingMemberIdProvider import com.hedvig.android.feature.onboarding.FakeOnboardingRepository import com.hedvig.android.feature.onboarding.data.CompleteOnboardingUseCase import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore @@ -38,7 +39,7 @@ internal class OnboardingPaymentPresenterTest { fun `not connected content when payin methods are missing`() = runTest { val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.ConnectPayment)) } val repository = FakeOnboardingRepository() - val sessionStore = OnboardingSessionStore(repository) + val sessionStore = OnboardingSessionStore(repository, FakeOnboardingMemberIdProvider()) val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) val presenter = OnboardingPaymentPresenter(sessionStore, navigator) @@ -56,7 +57,7 @@ internal class OnboardingPaymentPresenterTest { fun `connect payment pushes the trustly flow`() = runTest { val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.ConnectPayment)) } val repository = FakeOnboardingRepository() - val sessionStore = OnboardingSessionStore(repository) + val sessionStore = OnboardingSessionStore(repository, FakeOnboardingMemberIdProvider()) val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) val presenter = OnboardingPaymentPresenter(sessionStore, navigator) @@ -74,7 +75,7 @@ internal class OnboardingPaymentPresenterTest { fun `refresh after returning flips to connected`() = runTest { val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.ConnectPayment)) } val repository = FakeOnboardingRepository() - val sessionStore = OnboardingSessionStore(repository) + val sessionStore = OnboardingSessionStore(repository, FakeOnboardingMemberIdProvider()) val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) val presenter = OnboardingPaymentPresenter(sessionStore, navigator) @@ -96,7 +97,7 @@ internal class OnboardingPaymentPresenterTest { fun `continue advances to BundleDiscount`() = runTest { val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.ConnectPayment)) } val repository = FakeOnboardingRepository() - val sessionStore = OnboardingSessionStore(repository) + val sessionStore = OnboardingSessionStore(repository, FakeOnboardingMemberIdProvider()) val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) val presenter = OnboardingPaymentPresenter(sessionStore, navigator) diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdPresenterTest.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdPresenterTest.kt index 0378b3c7a5..7c0515c3b8 100644 --- a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdPresenterTest.kt +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdPresenterTest.kt @@ -5,6 +5,7 @@ import arrow.core.right import assertk.assertThat import assertk.assertions.isEqualTo import assertk.assertions.isInstanceOf +import com.hedvig.android.feature.onboarding.FakeOnboardingMemberIdProvider import com.hedvig.android.feature.onboarding.FakeOnboardingRepository import com.hedvig.android.feature.onboarding.data.CompleteOnboardingUseCase import com.hedvig.android.feature.onboarding.data.OnboardingContract @@ -38,7 +39,7 @@ internal class OnboardingPetIdPresenterTest { fun `rows are pinned from the contracts missing pet id at load`() = runTest { val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.PetIds)) } val repository = FakeOnboardingRepository() - val sessionStore = OnboardingSessionStore(repository) + val sessionStore = OnboardingSessionStore(repository, FakeOnboardingMemberIdProvider()) val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) val presenter = OnboardingPetIdPresenter(sessionStore, navigator) @@ -70,7 +71,7 @@ internal class OnboardingPetIdPresenterTest { fun `refresh marks completed rows instead of removing them`() = runTest { val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.PetIds)) } val repository = FakeOnboardingRepository() - val sessionStore = OnboardingSessionStore(repository) + val sessionStore = OnboardingSessionStore(repository, FakeOnboardingMemberIdProvider()) val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) val presenter = OnboardingPetIdPresenter(sessionStore, navigator) @@ -119,7 +120,7 @@ internal class OnboardingPetIdPresenterTest { fun `continue advances to InviteFriend`() = runTest { val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.PetIds)) } val repository = FakeOnboardingRepository() - val sessionStore = OnboardingSessionStore(repository) + val sessionStore = OnboardingSessionStore(repository, FakeOnboardingMemberIdProvider()) val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) val presenter = OnboardingPetIdPresenter(sessionStore, navigator) diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhonePresenterTest.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhonePresenterTest.kt index 1bf3a8296e..9fe50f8bb2 100644 --- a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhonePresenterTest.kt +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhonePresenterTest.kt @@ -8,6 +8,7 @@ import assertk.assertions.isEqualTo import assertk.assertions.isInstanceOf import assertk.assertions.isTrue import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.feature.onboarding.FakeOnboardingMemberIdProvider import com.hedvig.android.feature.onboarding.FakeOnboardingRepository import com.hedvig.android.feature.onboarding.data.CompleteOnboardingUseCase import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore @@ -40,7 +41,7 @@ internal class OnboardingPhonePresenterTest { fun `content pre-fills the member's phone number`() = runTest { val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.PhoneNumber)) } val repository = FakeOnboardingRepository() - val sessionStore = OnboardingSessionStore(repository) + val sessionStore = OnboardingSessionStore(repository, FakeOnboardingMemberIdProvider()) val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) val presenter = OnboardingPhonePresenter(sessionStore, navigator, repository) @@ -57,7 +58,7 @@ internal class OnboardingPhonePresenterTest { fun `save success advances to the next step`() = runTest { val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.PhoneNumber)) } val repository = FakeOnboardingRepository() - val sessionStore = OnboardingSessionStore(repository) + val sessionStore = OnboardingSessionStore(repository, FakeOnboardingMemberIdProvider()) val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) val presenter = OnboardingPhonePresenter(sessionStore, navigator, repository) @@ -79,7 +80,7 @@ internal class OnboardingPhonePresenterTest { fun `save failure shows inline error and does not advance`() = runTest { val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.PhoneNumber)) } val repository = FakeOnboardingRepository() - val sessionStore = OnboardingSessionStore(repository) + val sessionStore = OnboardingSessionStore(repository, FakeOnboardingMemberIdProvider()) val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) val presenter = OnboardingPhonePresenter(sessionStore, navigator, repository) @@ -102,7 +103,7 @@ internal class OnboardingPhonePresenterTest { fun `do this later advances without calling the mutation`() = runTest { val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.PhoneNumber)) } val repository = FakeOnboardingRepository() - val sessionStore = OnboardingSessionStore(repository) + val sessionStore = OnboardingSessionStore(repository, FakeOnboardingMemberIdProvider()) val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) val presenter = OnboardingPhonePresenter(sessionStore, navigator, repository) diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemePresenterTest.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemePresenterTest.kt index 592f2529cb..89a4aca39a 100644 --- a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemePresenterTest.kt +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemePresenterTest.kt @@ -5,6 +5,7 @@ import arrow.core.right import assertk.assertThat import assertk.assertions.isEqualTo import assertk.assertions.isInstanceOf +import com.hedvig.android.feature.onboarding.FakeOnboardingMemberIdProvider import com.hedvig.android.feature.onboarding.FakeOnboardingRepository import com.hedvig.android.feature.onboarding.FakeSettingsDataStore import com.hedvig.android.feature.onboarding.data.CompleteOnboardingUseCase @@ -39,7 +40,7 @@ internal class OnboardingThemePresenterTest { fun `selected theme defaults to SYSTEM_DEFAULT when nothing stored`() = runTest { val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.Theme)) } val repository = FakeOnboardingRepository() - val sessionStore = OnboardingSessionStore(repository) + val sessionStore = OnboardingSessionStore(repository, FakeOnboardingMemberIdProvider()) val settingsDataStore = FakeSettingsDataStore() val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) val presenter = OnboardingThemePresenter(sessionStore, navigator, settingsDataStore) @@ -57,7 +58,7 @@ internal class OnboardingThemePresenterTest { fun `selecting a theme persists it and updates the selection`() = runTest { val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.Theme)) } val repository = FakeOnboardingRepository() - val sessionStore = OnboardingSessionStore(repository) + val sessionStore = OnboardingSessionStore(repository, FakeOnboardingMemberIdProvider()) val settingsDataStore = FakeSettingsDataStore() val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) val presenter = OnboardingThemePresenter(sessionStore, navigator, settingsDataStore) @@ -77,7 +78,7 @@ internal class OnboardingThemePresenterTest { fun `continue advances to the next path step`() = runTest { val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.Theme)) } val repository = FakeOnboardingRepository() - val sessionStore = OnboardingSessionStore(repository) + val sessionStore = OnboardingSessionStore(repository, FakeOnboardingMemberIdProvider()) val settingsDataStore = FakeSettingsDataStore() val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) val presenter = OnboardingThemePresenter(sessionStore, navigator, settingsDataStore) diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomePresenterTest.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomePresenterTest.kt index 2f209d3f01..c12fc4975e 100644 --- a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomePresenterTest.kt +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomePresenterTest.kt @@ -7,6 +7,7 @@ import assertk.assertThat import assertk.assertions.isEqualTo import assertk.assertions.isInstanceOf import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.feature.onboarding.FakeOnboardingMemberIdProvider import com.hedvig.android.feature.onboarding.FakeOnboardingRepository import com.hedvig.android.feature.onboarding.data.CompleteOnboardingUseCase import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore @@ -39,7 +40,7 @@ internal class OnboardingWelcomePresenterTest { @Test fun `session fetch success shows content with the progress of the whole path`() = runTest { val repository = FakeOnboardingRepository() - val sessionStore = OnboardingSessionStore(repository) + val sessionStore = OnboardingSessionStore(repository, FakeOnboardingMemberIdProvider()) val navigator = OnboardingNavigator(TestBackstack(), sessionStore, NoopCompleteOnboardingUseCase()) val presenter = OnboardingWelcomePresenter(sessionStore, navigator) @@ -57,7 +58,7 @@ internal class OnboardingWelcomePresenterTest { @Test fun `session fetch failure shows error, retry refetches`() = runTest { val repository = FakeOnboardingRepository() - val sessionStore = OnboardingSessionStore(repository) + val sessionStore = OnboardingSessionStore(repository, FakeOnboardingMemberIdProvider()) val navigator = OnboardingNavigator(TestBackstack(), sessionStore, NoopCompleteOnboardingUseCase()) val presenter = OnboardingWelcomePresenter(sessionStore, navigator) @@ -76,7 +77,7 @@ internal class OnboardingWelcomePresenterTest { fun `get started pushes the first path step onto the backstack`() = runTest { val backstack = TestBackstack().apply { entries.add(OnboardingKey) } val repository = FakeOnboardingRepository() - val sessionStore = OnboardingSessionStore(repository) + val sessionStore = OnboardingSessionStore(repository, FakeOnboardingMemberIdProvider()) val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) val presenter = OnboardingWelcomePresenter(sessionStore, navigator) From 958d96fad0885b34ec0eeb3e2394de0908bebdc1 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 21 Jul 2026 18:45:02 +0200 Subject: [PATCH 027/119] Reset phone step submitting state after save --- .../onboarding/ui/phone/OnboardingPhoneDestination.kt | 6 +++++- .../onboarding/ui/phone/OnboardingPhonePresenterTest.kt | 3 +++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt index 6a9b390eba..5133395623 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt @@ -85,9 +85,13 @@ internal class OnboardingPhonePresenter( phoneNumber = content.phoneNumber, ).fold( ifLeft = { - currentState = content.copy(isSubmitting = false, showSubmissionError = true) + // Derive from the live state so digits typed while submitting are not lost. + currentState = (currentState as? OnboardingPhoneUiState.Content ?: content) + .copy(isSubmitting = false, showSubmissionError = true) }, ifRight = { + // Reset before navigating so returning to this retained entry does not leave Save disabled. + currentState = content.copy(isSubmitting = false) navigator.continueFrom(OnboardingStepId.PhoneNumber) }, ) diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhonePresenterTest.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhonePresenterTest.kt index 9fe50f8bb2..ed1cbf626f 100644 --- a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhonePresenterTest.kt +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhonePresenterTest.kt @@ -72,6 +72,9 @@ internal class OnboardingPhonePresenterTest { repository.updateContactInfoResponses.add(Unit.right()) awaitItem() // phone number updated, not yet submitting awaitItem() // isSubmitting = true; continueFrom already ran during runCurrent() above + val settled = awaitItem() // isSubmitting reset to false before navigating away + assertThat(settled).isInstanceOf() + assertThat((settled as OnboardingPhoneUiState.Content).isSubmitting).isEqualTo(false) assertThat(backstack.entries.last()).isEqualTo(OnboardingStepKey(OnboardingStepId.Theme)) } } From 5ca6d033aea14ca14a5df3d81589708a35655ea8 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 21 Jul 2026 18:47:52 +0200 Subject: [PATCH 028/119] Anchor onboarding step content above the bottom buttons --- .../android/feature/onboarding/ui/OnboardingStepScaffold.kt | 3 ++- .../onboarding/ui/bundle/OnboardingBundleDestination.kt | 1 + .../onboarding/ui/invite/OnboardingInviteDestination.kt | 3 ++- .../feature/onboarding/ui/phone/OnboardingPhoneDestination.kt | 1 + .../onboarding/ui/welcome/OnboardingWelcomeDestination.kt | 1 + 5 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt index fe6ddb5d73..7a483274b3 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt @@ -165,7 +165,6 @@ internal fun ColumnScope.OnboardingStepButtons( secondaryText: String? = null, onSecondaryClick: (() -> Unit)? = null, ) { - Spacer(Modifier.weight(1f)) Spacer(Modifier.height(16.dp)) HedvigButton( text = primaryText, @@ -203,6 +202,7 @@ private fun PreviewOnboardingStepScaffold() { title = "Step title", description = "Step description with some helpful text.", ) + Spacer(Modifier.weight(1f)) OnboardingStepButtons( primaryText = "Continue", onPrimaryClick = {}, @@ -227,6 +227,7 @@ private fun PreviewOnboardingStepScaffoldNoProgress() { title = "Welcome", description = "Welcome to Hedvig onboarding.", ) + Spacer(Modifier.weight(1f)) OnboardingStepButtons( primaryText = "Get started", onPrimaryClick = {}, diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt index d85d3e04c4..f67c7913fa 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt @@ -157,6 +157,7 @@ internal fun OnboardingBundleDestination( ) } } + Spacer(Modifier.weight(1f)) OnboardingStepButtons( // TODO: Add "Continue to app" / "Fortsätt till appen" to Lokalise primaryText = "Continue to app", diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt index a3385c1dd9..b15a6ee7d7 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt @@ -138,11 +138,12 @@ internal fun OnboardingInviteDestination(viewModel: OnboardingInviteViewModel, n // TODO: Add the body copy below (and its Swedish translation) to Lokalise description = "With Hedvig Forever, you get ${content.incentiveDisplay} off for every friend you invite", ) - Spacer(Modifier.height(16.dp)) + Spacer(Modifier.weight(1f)) HedvigText( text = content.code, modifier = Modifier.align(Alignment.CenterHorizontally), ) + Spacer(Modifier.weight(1f)) OnboardingStepButtons( // TODO: Add "Continue" / "Fortsätt" to Lokalise primaryText = "Continue", diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt index 5133395623..ce429dd919 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt @@ -198,6 +198,7 @@ internal fun OnboardingPhoneDestination(viewModel: OnboardingPhoneViewModel, nav .fillMaxWidth() .padding(horizontal = 16.dp), ) + Spacer(Modifier.weight(1f)) OnboardingStepButtons( // TODO: Add "Save" / "Spara" to Lokalise primaryText = "Save", diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt index 960ce660d0..e0c060fcf8 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt @@ -135,6 +135,7 @@ internal fun OnboardingWelcomeDestination(viewModel: OnboardingWelcomeViewModel) color = HedvigTheme.colorScheme.textSecondary, ) } + Spacer(Modifier.weight(1f)) // TODO: Add "Get started" / "Kom igång" to Lokalise OnboardingStepButtons( primaryText = "Get started", From 27e3a454080984b44b2b70d8ae16a0535596375a Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 21 Jul 2026 18:52:57 +0200 Subject: [PATCH 029/119] Polish onboarding UI details and comments --- app/feature/feature-onboarding/build.gradle.kts | 1 + .../feature/onboarding/navigation/OnboardingNavigator.kt | 5 ----- .../feature/onboarding/ui/OnboardingStepScaffold.kt | 1 + .../onboarding/ui/bundle/OnboardingBundleDestination.kt | 3 +-- .../onboarding/ui/invite/OnboardingInviteDestination.kt | 7 +++---- .../onboarding/ui/theme/OnboardingThemeDestination.kt | 5 +++-- .../tracking/firebase/ConsentAwareEventTrackingClient.kt | 1 + 7 files changed, 10 insertions(+), 13 deletions(-) diff --git a/app/feature/feature-onboarding/build.gradle.kts b/app/feature/feature-onboarding/build.gradle.kts index 6f493ac17a..7efc1dca75 100644 --- a/app/feature/feature-onboarding/build.gradle.kts +++ b/app/feature/feature-onboarding/build.gradle.kts @@ -12,6 +12,7 @@ hedvig { } android { + // Compose's Recomposer logs via android.util.Log in unit tests; return defaults instead of throwing. testOptions.unitTests.isReturnDefaultValues = true } diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingNavigator.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingNavigator.kt index 493548f6a7..c7319f99aa 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingNavigator.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingNavigator.kt @@ -61,9 +61,4 @@ internal class OnboardingNavigator( fun openConnectPayment() { backstack.add(TrustlyKey) } - - /** Reserved for the top app bar back arrow only, per the navigation rule. */ - fun navigateBack() { - backstack.navigateUp() - } } diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt index 7a483274b3..730b258c84 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt @@ -152,6 +152,7 @@ private fun OnboardingProgressBar(progress: OnboardingProgress, modifier: Modifi internal fun OnboardingStepHeader(title: String, description: String, modifier: Modifier = Modifier) { Column(modifier.padding(horizontal = 16.dp)) { HedvigText(text = title) + Spacer(Modifier.height(4.dp)) HedvigText(text = description, color = HedvigTheme.colorScheme.textSecondary) } } diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt index f67c7913fa..94486edbc1 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt @@ -130,7 +130,6 @@ internal fun OnboardingBundleDestination( } is OnboardingBundleUiState.Content -> { - val content = state Spacer(Modifier.height(8.dp)) OnboardingStepHeader( // TODO: Add "Get bundle discount" / "Få paketrabatt" to Lokalise @@ -139,7 +138,7 @@ internal fun OnboardingBundleDestination( description = "You get a bundle discount when you have two or more insurances with us", ) Spacer(Modifier.height(16.dp)) - for (crossSell in content.crossSells) { + for (crossSell in state.crossSells) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt index b15a6ee7d7..b09e97a865 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt @@ -130,17 +130,16 @@ internal fun OnboardingInviteDestination(viewModel: OnboardingInviteViewModel, n } is OnboardingInviteUiState.Content -> { - val content = state Spacer(Modifier.height(8.dp)) OnboardingStepHeader( // TODO: Add "Invite a friend" / "Bjud in en vän" to Lokalise title = "Invite a friend", // TODO: Add the body copy below (and its Swedish translation) to Lokalise - description = "With Hedvig Forever, you get ${content.incentiveDisplay} off for every friend you invite", + description = "With Hedvig Forever, you get ${state.incentiveDisplay} off for every friend you invite", ) Spacer(Modifier.weight(1f)) HedvigText( - text = content.code, + text = state.code, modifier = Modifier.align(Alignment.CenterHorizontally), ) Spacer(Modifier.weight(1f)) @@ -155,7 +154,7 @@ internal fun OnboardingInviteDestination(viewModel: OnboardingInviteViewModel, n // REFERRAL_SMS_MESSAGE once product confirms. context.showShareSheet( title = "Invite a friend", - text = "With my code you get a discount at Hedvig. Use code ${content.code}.", + text = "With my code you get a discount at Hedvig. Use code ${state.code}.", ) }, ) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt index 8abb8dfe12..0457b9c779 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt @@ -84,7 +84,8 @@ internal class OnboardingThemePresenter( ifRight = { session -> currentState = OnboardingThemeUiState.Content( progress = session.progressFor(OnboardingStepId.Theme), - selectedTheme = storedTheme ?: Theme.SYSTEM_DEFAULT, + // Placeholder; the return site merges the live stored theme. + selectedTheme = Theme.SYSTEM_DEFAULT, ) }, ) @@ -218,7 +219,7 @@ private fun ThemeOptionRow( .fillMaxWidth() .clip(HedvigTheme.shapes.cornerMedium) .clickable(onClick = onClick) - .padding(16.dp), + .padding(vertical = 16.dp), ) { Column(Modifier.weight(1f)) { HedvigText(title) diff --git a/app/tracking/tracking-firebase/src/main/kotlin/com/hedvig/android/tracking/firebase/ConsentAwareEventTrackingClient.kt b/app/tracking/tracking-firebase/src/main/kotlin/com/hedvig/android/tracking/firebase/ConsentAwareEventTrackingClient.kt index 76debff022..e063484968 100644 --- a/app/tracking/tracking-firebase/src/main/kotlin/com/hedvig/android/tracking/firebase/ConsentAwareEventTrackingClient.kt +++ b/app/tracking/tracking-firebase/src/main/kotlin/com/hedvig/android/tracking/firebase/ConsentAwareEventTrackingClient.kt @@ -151,6 +151,7 @@ internal class ConsentAwareEventTrackingClient( } } +// Must stay public: Metro only discovers cross-module contributions with public hints (see CLAUDE.md). @ContributesTo(AppScope::class) @Suppress("EXPOSED_PARAMETER_TYPE") interface ConsentAwareEventTrackingClientProviders { From 2e8df460ee6ac6c5e1975deb29b552adac9b20c7 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 22 Jul 2026 09:31:10 +0200 Subject: [PATCH 030/119] Disable Firebase SDK collection while analytics consent is denied When consent is DENIED, the wrapper now forces setCollectionEnabled(false) on the Firebase SDK (suppressing automatic events too), not only our custom-tracked events. Collection re-enables on GRANTED, subject to the demo-mode gate, by ANDing the demo flag with the consent state. --- .superpowers/sdd/task-20-fixes-report.md | 130 ++++++++++++++++++ .../ConsentAwareEventTrackingClient.kt | 23 +++- .../ConsentAwareEventTrackingClientTest.kt | 43 +++++- .../specs/2026-07-21-onboarding-design.md | 3 + 4 files changed, 193 insertions(+), 6 deletions(-) create mode 100644 .superpowers/sdd/task-20-fixes-report.md diff --git a/.superpowers/sdd/task-20-fixes-report.md b/.superpowers/sdd/task-20-fixes-report.md new file mode 100644 index 0000000000..cd96485aed --- /dev/null +++ b/.superpowers/sdd/task-20-fixes-report.md @@ -0,0 +1,130 @@ +# Task 20 whole-branch review fixes + +Branch: `feat/onboarding`. All work under `app/feature/feature-onboarding` unless noted. + +## Commits + +| SHA | Subject | +| --- | --- | +| 594f931a5b | Scope the onboarding session cache to the current member | +| 958d96fad0 | Reset phone step submitting state after save | +| 5ca6d033ae | Anchor onboarding step content above the bottom buttons | +| 27e3a45408 | Polish onboarding UI details and comments | + +## A. Member-scoped OnboardingSessionStore cache (CRITICAL) + +`data/OnboardingSessionStore.kt`: +- Added internal `fun interface OnboardingMemberIdProvider { fun memberId(): Flow }` plus + `OnboardingMemberIdProviderImpl` (`@ContributesBinding(AppScope::class)`, `@Inject`) delegating to + `MemberIdService.getMemberId()`. +- `OnboardingSession` gained `val memberId: String` as its first property. +- `OnboardingSessionStore` constructor now takes `memberIdProvider`. + - `getOrFetchSession()` (inside the mutex): resolves `currentMemberId` (returns + `ErrorMessage("No member id for onboarding session").left()` if null); returns the cached + session only when `cachedSession?.memberId == currentMemberId`, otherwise clears the cache and + fetches fresh, storing the session stamped with the current member id. + - `refreshData()`: resolves the member id, treats a cached session with a different memberId as + "no session to refresh" (clears cache, returns the existing error path). +- `OnboardingGateImpl` (`gate/OnboardingGate.kt`) unchanged and still compiles: it resolves memberId + itself then calls `getOrFetchSession()`; the double resolution is harmless. +- Tests: added `FakeOnboardingMemberIdProvider(var memberId)` to `FakeOnboardingRepository.kt`; + updated all 30 `OnboardingSessionStore(...)` construction sites across 10 test files (phone, theme, + consent, welcome, coinsured, petid, invite, payment, bundle presenter tests + navigator test) to + pass the fake, with the import added (alphabetically before `FakeOnboardingRepository`). +- New `data/OnboardingSessionStoreTest.kt`: + - `same member reuses the cached session without refetching` — one repository response fed, two + `getOrFetchSession()` calls return equal sessions, `expectNoEvents()` on the Turbine proves no + refetch. + - `a member change discards the cache and fetches fresh` — member A response (phone 111) cached, + fake switched to member B, second response (phone 222) fed, second call returns the member-B + session. + +## B. Phone step submit-state bugs + +`ui/phone/OnboardingPhoneDestination.kt` submit LaunchedEffect: +- On success: `currentState = content.copy(isSubmitting = false)` BEFORE `navigator.continueFrom(...)`. +- On failure: `currentState = (currentState as? OnboardingPhoneUiState.Content ?: content).copy(isSubmitting = false, showSubmissionError = true)` so digits typed during submission survive. +- Test `save success advances to the next step` updated for the extra emission and asserts the final + emitted Content has `isSubmitting == false`. + +## C. Single ownership of vertical free space + +`ui/OnboardingStepScaffold.kt`: removed the leading `Spacer(Modifier.weight(1f))` from +`OnboardingStepButtons` (kept its 16dp spacing); added a `Spacer(weight(1f))` to both previews. +Per-destination arrangement now has exactly one `weight(1f)` split each: +- welcome: added the second `Spacer(weight(1f))` after the centered content block. +- phone: added `Spacer(weight(1f))` between the text field and the buttons. +- invite: added `Spacer(weight(1f))` before and after the centered code text. +- bundle: added `Spacer(weight(1f))` after the rows loop. +- consent, theme, coinsured, petid, payment (both connected/not-connected): already had the single + weight in the required place, left unchanged. + +## D. Small quality fixes + +1. `OnboardingStepHeader`: `Spacer(Modifier.height(4.dp))` between title and description. +2. `ui/theme/OnboardingThemeDestination.kt`: (a) load-effect initial Content now uses + `selectedTheme = Theme.SYSTEM_DEFAULT` with comment `// Placeholder; the return site merges the + live stored theme.` (b) `ThemeOptionRow` internal padding changed `padding(16.dp)` -> + `padding(vertical = 16.dp)`; call sites keep horizontal 16dp so the total horizontal inset is 16dp + and the ripple (clip+clickable before the padding) still covers the row. +3. `navigation/OnboardingNavigator.kt`: removed the dead `navigateBack()` (no source references). +4. `tracking-firebase/.../ConsentAwareEventTrackingClient.kt`: added the public-hints comment on the + providers interface (ktlint moved it above the annotations, which is the correct spot). +5. `build.gradle.kts`: comment above `isReturnDefaultValues = true`. +6. Removed redundant `val content = state` aliases in invite and bundle destinations (use the + smart-cast `state` directly). + +## Verification + +Commands run (grepped for BUILD SUCCESSFUL, piped exit codes not trusted): + +- `./gradlew :feature-onboarding:testDebugUnitTest :tracking-firebase:testDebugUnitTest` -> BUILD SUCCESSFUL +- `./gradlew :app:compileDebugKotlin` -> BUILD SUCCESSFUL +- `./gradlew ktlintFormat` -> BUILD SUCCESSFUL (only reflowed the new tracking-firebase comment, + committed in D) + +Working tree clean after the four commits. + +## Deviations + +None. The theme ThemeOptionRow modifier order (clip -> clickable -> padding) already produced a +ripple covering the row, so no reorder beyond the padding change was needed. + +## Fix: DENIED disables SDK collection + +### What changed + +`ConsentAwareEventTrackingClient.kt`: +- Added `private var collectionRequestedEnabled: Boolean = true` (guarded by `lock`). +- `setCollectionEnabled(enabled)` now stores the value under the lock, then calls + `applyCollectionEnabled()` instead of delegating directly. +- New `private fun applyCollectionEnabled()`: computes `effective = collectionRequestedEnabled && + consent != DENIED` under the lock, then calls `delegate.setCollectionEnabled(effective)` outside + it. NOT_DECIDED and GRANTED leave collection enabled (subject to the demo gate); only DENIED forces + it off; flipping back to GRANTED re-enables if the demo gate allows. +- The consent collector's `init` block now calls `applyCollectionEnabled()` after every consent + update. +- KDoc updated to document the DENIED-disables-SDK-collection behavior. + +`ConsentAwareEventTrackingClientTest.kt`: +- `RecordingClient.setCollectionEnabled` now records each call into `collectionEnabledCalls`. +- Three new tests added: + - `denying consent disables SDK collection and granting re-enables it` + - `demo mode wins over granted consent` + - `enabling collection while denied stays disabled` + +`docs/superpowers/specs/2026-07-21-onboarding-design.md`: +- Appended one sentence to the "Analytics consent mechanism" section documenting the post-review + product decision. + +### Commands run + +``` +./gradlew :tracking-firebase:testDebugUnitTest 2>&1 | tee /tmp/test_output.txt +grep "BUILD SUCCESSFUL" /tmp/test_output.txt -> BUILD SUCCESSFUL in 1m 3s + +./gradlew ktlintFormat 2>&1 | tee /tmp/ktlint_output.txt +grep "BUILD SUCCESSFUL" /tmp/ktlint_output.txt -> BUILD SUCCESSFUL in 41s +``` + +Both BUILD SUCCESSFUL outputs confirmed. ktlintFormat made no further changes to the modified files. diff --git a/app/tracking/tracking-firebase/src/main/kotlin/com/hedvig/android/tracking/firebase/ConsentAwareEventTrackingClient.kt b/app/tracking/tracking-firebase/src/main/kotlin/com/hedvig/android/tracking/firebase/ConsentAwareEventTrackingClient.kt index e063484968..02b4069d76 100644 --- a/app/tracking/tracking-firebase/src/main/kotlin/com/hedvig/android/tracking/firebase/ConsentAwareEventTrackingClient.kt +++ b/app/tracking/tracking-firebase/src/main/kotlin/com/hedvig/android/tracking/firebase/ConsentAwareEventTrackingClient.kt @@ -19,10 +19,15 @@ import kotlinx.coroutines.launch * - [AnalyticsConsent.NOT_DECIDED]: events/screens are buffered in-memory (bounded), not forwarded. * - [AnalyticsConsent.GRANTED]: the buffer is flushed (stamped with `buffered_at_epoch_ms`) and * subsequent events forward live. - * - [AnalyticsConsent.DENIED]: the buffer is dropped and nothing is forwarded. - * Identity calls ([setUserId], [setUserProperty]) and [setCollectionEnabled] (demo-mode gating) - * always pass through. Datadog is untouched by design: it carries performance and bug analytics, - * not product analytics. + * - [AnalyticsConsent.DENIED]: the buffer is dropped and nothing is forwarded. Additionally, the + * Firebase SDK's own collection is disabled (automatic events included) by calling + * [EventTrackingClient.setCollectionEnabled] with `false`. Collection is re-enabled when consent + * returns to GRANTED, subject to the demo-mode gate. This is a product decision: DENIED must + * suppress automatic Firebase events, not only our custom-tracked ones. + * Identity calls ([setUserId], [setUserProperty]) always pass through. [setCollectionEnabled] is + * the demo-mode gate; the effective value forwarded to the delegate is an AND of the demo gate and + * whether consent is not DENIED. Datadog is untouched by design: it carries performance and bug + * analytics, not product analytics. * * Deliberately NOT annotated with Metro annotations: annotating it `@ContributesBinding` for * [EventTrackingClient] while also injecting an [EventTrackingClient] delegate would self-loop. @@ -38,6 +43,7 @@ internal class ConsentAwareEventTrackingClient( ) : EventTrackingClient { private val lock = Any() private var consent: AnalyticsConsent = AnalyticsConsent.NOT_DECIDED + private var collectionRequestedEnabled: Boolean = true private val buffer = ArrayDeque() init { @@ -60,6 +66,7 @@ internal class ConsentAwareEventTrackingClient( } } } + applyCollectionEnabled() if (toFlush.isNotEmpty()) { logcat { "Analytics consent granted, flushing ${toFlush.size} buffered events" } for (call in toFlush) { @@ -71,7 +78,13 @@ internal class ConsentAwareEventTrackingClient( } override fun setCollectionEnabled(enabled: Boolean) { - delegate.setCollectionEnabled(enabled) + synchronized(lock) { collectionRequestedEnabled = enabled } + applyCollectionEnabled() + } + + private fun applyCollectionEnabled() { + val effective = synchronized(lock) { collectionRequestedEnabled && consent != AnalyticsConsent.DENIED } + delegate.setCollectionEnabled(effective) } override fun trackEvent(name: String, parameters: Map) { diff --git a/app/tracking/tracking-firebase/src/test/kotlin/com/hedvig/android/tracking/firebase/ConsentAwareEventTrackingClientTest.kt b/app/tracking/tracking-firebase/src/test/kotlin/com/hedvig/android/tracking/firebase/ConsentAwareEventTrackingClientTest.kt index c89591795f..c490c7b528 100644 --- a/app/tracking/tracking-firebase/src/test/kotlin/com/hedvig/android/tracking/firebase/ConsentAwareEventTrackingClientTest.kt +++ b/app/tracking/tracking-firebase/src/test/kotlin/com/hedvig/android/tracking/firebase/ConsentAwareEventTrackingClientTest.kt @@ -27,8 +27,11 @@ class ConsentAwareEventTrackingClientTest { val events = mutableListOf>>() val screens = mutableListOf() var recordedUserId: String? = null + val collectionEnabledCalls = mutableListOf() - override fun setCollectionEnabled(enabled: Boolean) {} + override fun setCollectionEnabled(enabled: Boolean) { + collectionEnabledCalls.add(enabled) + } override fun trackEvent(name: String, parameters: Map) { events.add(name to parameters) @@ -123,4 +126,42 @@ class ConsentAwareEventTrackingClientTest { client.setUserId("member-id") assertThat(recording.recordedUserId).isEqualTo("member-id") } + + @Test + fun `denying consent disables SDK collection and granting re-enables it`() = runTest { + val recording = RecordingClient() + val settings = FakeSettingsDataStore() + client(recording, settings) + runCurrent() + settings.consent.value = AnalyticsConsent.DENIED + runCurrent() + assertThat(recording.collectionEnabledCalls.last()).isEqualTo(false) + settings.consent.value = AnalyticsConsent.GRANTED + runCurrent() + assertThat(recording.collectionEnabledCalls.last()).isEqualTo(true) + } + + @Test + fun `demo mode wins over granted consent`() = runTest { + val recording = RecordingClient() + val settings = FakeSettingsDataStore() + val client = client(recording, settings) + runCurrent() + client.setCollectionEnabled(false) + settings.consent.value = AnalyticsConsent.GRANTED + runCurrent() + assertThat(recording.collectionEnabledCalls.last()).isEqualTo(false) + } + + @Test + fun `enabling collection while denied stays disabled`() = runTest { + val recording = RecordingClient() + val settings = FakeSettingsDataStore() + val client = client(recording, settings) + runCurrent() + settings.consent.value = AnalyticsConsent.DENIED + runCurrent() + client.setCollectionEnabled(true) + assertThat(recording.collectionEnabledCalls.last()).isEqualTo(false) + } } diff --git a/docs/superpowers/specs/2026-07-21-onboarding-design.md b/docs/superpowers/specs/2026-07-21-onboarding-design.md index 8b87968a99..967db53775 100644 --- a/docs/superpowers/specs/2026-07-21-onboarding-design.md +++ b/docs/superpowers/specs/2026-07-21-onboarding-design.md @@ -217,6 +217,9 @@ The segmented progress bar is expected to be a new small component; check `SettingsPresenter`) reading/writing the same value, fulfilling the design's "can be turned off any time in settings". - The onboarding step writes `Granted` on Allow and `Denied` on Deny. + On DENIED, the wrapper also forces `setAnalyticsCollectionEnabled(false)` on the Firebase SDK + (automatic events included), re-enabled on GRANTED subject to the demo-mode gate; decided + post-review. ## Error handling From fdeb5e7db0ea3f9d3cd2f524aa5e928de097c6dd Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 22 Jul 2026 11:52:20 +0200 Subject: [PATCH 031/119] Add debug-only reset for the onboarding seen flag --- .../com/hedvig/android/app/MainActivity.kt | 5 +++ .../app/navigation/HedvigEntryProvider.kt | 11 ++++++ .../com/hedvig/android/app/ui/HedvigApp.kt | 3 ++ .../onboarding/data/OnboardingSeenStore.kt | 7 ++++ .../data/ResetOnboardingSeenUseCase.kt | 35 +++++++++++++++++++ .../data/OnboardingSeenStoreTest.kt | 10 ++++++ .../aboutapp/InformationDestination.kt | 19 ++++++++++ .../feature/profile/tab/ProfileEntries.kt | 2 ++ 8 files changed, 92 insertions(+) create mode 100644 app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/ResetOnboardingSeenUseCase.kt 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 9052cf094e..6214624461 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 @@ -42,6 +42,7 @@ import com.hedvig.android.core.demomode.DemoManager import com.hedvig.android.core.rive.RiveInitializer import com.hedvig.android.core.tracking.EventTrackingClient import com.hedvig.android.data.settings.datastore.SettingsDataStore +import com.hedvig.android.feature.onboarding.data.ResetOnboardingSeenUseCase import com.hedvig.android.featureflags.FeatureManager import com.hedvig.android.language.LanguageLaunchCheckUseCase import com.hedvig.android.language.LanguageService @@ -114,6 +115,9 @@ class MainActivity : AppCompatActivity() { @Inject private lateinit var serializersModules: Set + @Inject + private lateinit var resetOnboardingSeenUseCase: ResetOnboardingSeenUseCase + /** * Per-Activity host for the navigation state. A retained `ViewModel`, so it (and the * [BackstackController] it owns) survives a config change but dies with this Activity — giving each @@ -275,6 +279,7 @@ class MainActivity : AppCompatActivity() { eventTrackingClient = eventTrackingClient, screenParameterExtractor = screenParameterExtractor, onboardingGate = navRetainedViewModel.onboardingGate, + resetOnboardingSeenUseCase = resetOnboardingSeenUseCase, ) } } 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 043f924bb3..a240ffc81a 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 @@ -47,6 +47,7 @@ import com.hedvig.android.feature.insurances.navigation.insuranceEntries import com.hedvig.android.feature.login.navigation.loginEntries import com.hedvig.android.feature.movingflow.SelectContractForMovingKey import com.hedvig.android.feature.movingflow.movingFlowEntries +import com.hedvig.android.feature.onboarding.data.ResetOnboardingSeenUseCase import com.hedvig.android.feature.onboarding.navigation.onboardingEntries import com.hedvig.android.feature.payments.navigation.paymentsEntries import com.hedvig.android.feature.payoutaccount.navigation.PayoutAccountKey @@ -95,6 +96,7 @@ internal fun EntryProviderScope.hedvigEntryProvider( imageLoader: ImageLoader, languageService: LanguageService, hedvigBuildConstants: HedvigBuildConstants, + resetOnboardingSeenUseCase: ResetOnboardingSeenUseCase, ) { val shouldShowRequestPermissionRationale: (String) -> Boolean = androidAppHost::shouldShowPermissionRationale val navigateToConnectPayment: () -> Unit = { backstack.add(TrustlyKey) } @@ -150,6 +152,13 @@ internal fun EntryProviderScope.hedvigEntryProvider( navigateToConnectPayment = navigateToConnectPayment, navigateToPayoutAccount = navigateToPayoutAccount, navigateToNewConversation = navigateToNewConversation, + onResetOnboardingForDebug = { + scope.launch { + resetOnboardingSeenUseCase.invoke() + // Debug-only feedback for non-production builds; deliberately not translated. + globalSnackBarState.show("Onboarding reset. Background and foreground the app to see it.") + } + }, ) addChatEntries( backstack = backstack, @@ -440,6 +449,7 @@ private fun EntryProviderScope.addProfileEntries( navigateToConnectPayment: () -> Unit, navigateToPayoutAccount: () -> Unit, navigateToNewConversation: () -> Unit, + onResetOnboardingForDebug: () -> Unit, ) { profileEntries( settingsDestinationNestedGraphs = { @@ -468,6 +478,7 @@ private fun EntryProviderScope.addProfileEntries( openUrl = openUrl, navigateToChipId = { backstack.add(ChipIdKey()) }, languageService = languageService, + onResetOnboardingForDebug = onResetOnboardingForDebug, ) } 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 509e9b1f26..6be59712a8 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 @@ -69,6 +69,7 @@ import com.hedvig.android.design.system.hedvig.motion.MotionDefaults import com.hedvig.android.design.system.hedvig.rememberGlobalSnackBarState import com.hedvig.android.feature.cross.sell.sheet.CrossSellSheet import com.hedvig.android.feature.home.home.navigation.HomeKey +import com.hedvig.android.feature.onboarding.data.ResetOnboardingSeenUseCase import com.hedvig.android.feature.onboarding.gate.OnboardingGate import com.hedvig.android.feature.onboarding.navigation.OnboardingKey import com.hedvig.android.featureflags.FeatureManager @@ -120,6 +121,7 @@ internal fun HedvigApp( eventTrackingClient: EventTrackingClient, screenParameterExtractor: ScreenParameterExtractor, onboardingGate: OnboardingGate, + resetOnboardingSeenUseCase: ResetOnboardingSeenUseCase, ) { ReportCurrentDestinationEffect(backstackController, currentDestinationHolder) TrackScreenViewEffect(backstackController, eventTrackingClient, screenParameterExtractor) @@ -226,6 +228,7 @@ internal fun HedvigApp( imageLoader = imageLoader, languageService = languageService, hedvigBuildConstants = hedvigBuildConstants, + resetOnboardingSeenUseCase = resetOnboardingSeenUseCase, ) }, ) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingSeenStore.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingSeenStore.kt index 1a03021202..f47f365e78 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingSeenStore.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingSeenStore.kt @@ -20,6 +20,9 @@ internal interface OnboardingSeenStore { suspend fun hasSeenOnboarding(memberId: String): Boolean suspend fun markOnboardingSeen(memberId: String) + + /** Debug affordance only: clears the flag so onboarding can be triggered again. */ + suspend fun resetOnboardingSeen(memberId: String) } @ContributesBinding(AppScope::class) @@ -36,6 +39,10 @@ internal class DataStoreOnboardingSeenStore( dataStore.edit { it[seenKey(memberId)] = true } } + override suspend fun resetOnboardingSeen(memberId: String) { + dataStore.edit { it.remove(seenKey(memberId)) } + } + private fun seenKey(memberId: String): Preferences.Key { return booleanPreferencesKey("com.hedvig.android.feature.onboarding.seen.$memberId") } diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/ResetOnboardingSeenUseCase.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/ResetOnboardingSeenUseCase.kt new file mode 100644 index 0000000000..d8d6ace45c --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/ResetOnboardingSeenUseCase.kt @@ -0,0 +1,35 @@ +package com.hedvig.android.feature.onboarding.data + +import com.hedvig.android.auth.MemberIdService +import com.hedvig.android.core.common.di.AppScope +import com.hedvig.android.logger.logcat +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import kotlinx.coroutines.flow.first + +/** + * Debug affordance: clears the current member's onboarding-seen flag so the flow can be triggered + * again (on the next resume of an idle Home screen). Exposed publicly so `:app` can wire it to the + * non-production "Reset onboarding" row in the profile About app screen. Not reachable in + * production UI. + */ +interface ResetOnboardingSeenUseCase { + suspend fun invoke() +} + +@ContributesBinding(AppScope::class) +@Inject +internal class ResetOnboardingSeenUseCaseImpl( + private val memberIdService: MemberIdService, + private val onboardingSeenStore: OnboardingSeenStore, +) : ResetOnboardingSeenUseCase { + override suspend fun invoke() { + val memberId = memberIdService.getMemberId().first() + if (memberId == null) { + logcat { "Reset onboarding requested without a member id; nothing to reset" } + return + } + onboardingSeenStore.resetOnboardingSeen(memberId) + logcat { "Onboarding seen flag reset for the current member" } + } +} diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingSeenStoreTest.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingSeenStoreTest.kt index 6e649a9cc2..6e1445da73 100644 --- a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingSeenStoreTest.kt +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingSeenStoreTest.kt @@ -34,4 +34,14 @@ class OnboardingSeenStoreTest { assertThat(store.hasSeenOnboarding("123")).isTrue() assertThat(store.hasSeenOnboarding("456")).isFalse() } + + @Test + fun `resetting clears the flag for that member only`() = runTest { + val store = store() + store.markOnboardingSeen("123") + store.markOnboardingSeen("456") + store.resetOnboardingSeen("123") + assertThat(store.hasSeenOnboarding("123")).isFalse() + assertThat(store.hasSeenOnboarding("456")).isTrue() + } } diff --git a/app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/aboutapp/InformationDestination.kt b/app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/aboutapp/InformationDestination.kt index 492abcaee5..da86e1e9ed 100644 --- a/app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/aboutapp/InformationDestination.kt +++ b/app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/aboutapp/InformationDestination.kt @@ -82,6 +82,7 @@ internal fun InformationDestination( hedvigBuildConstants: HedvigBuildConstants, languageService: LanguageService, openUrl: (String) -> Unit, + onResetOnboardingForDebug: () -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() InformationScreen( @@ -94,6 +95,7 @@ internal fun InformationDestination( navigateToNewConversation = navigateToNewConversation, openUrl = openUrl, languageService = languageService, + onResetOnboardingForDebug = onResetOnboardingForDebug, ) } @@ -108,6 +110,7 @@ private fun InformationScreen( navigateToNewConversation: () -> Unit, openUrl: (String) -> Unit, languageService: LanguageService, + onResetOnboardingForDebug: () -> Unit, ) { HedvigScaffold( topAppBarText = stringResource(Res.string.PROFILE_INFO_LABEL), @@ -134,6 +137,7 @@ private fun InformationScreen( navigateToNewConversation = navigateToNewConversation, openUrl = openUrl, languageService = languageService, + onResetOnboardingForDebug = onResetOnboardingForDebug, ) } } @@ -151,6 +155,7 @@ private fun ColumnScope.InformationContent( navigateToNewConversation: () -> Unit, openUrl: (String) -> Unit, languageService: LanguageService, + onResetOnboardingForDebug: () -> Unit, ) { Spacer(Modifier.height(16.dp)) LegalInfoSection( @@ -234,6 +239,19 @@ private fun ColumnScope.InformationContent( ) { HedvigText(stringResource(Res.string.PROFILE_ABOUT_APP_LICENSE_ATTRIBUTIONS)) } + if (!isProduction) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onResetOnboardingForDebug) + .padding(16.dp), + ) { + // Debug-only affordance for non-production builds; deliberately not translated. + HedvigText("Reset onboarding") + } + } Spacer(Modifier.height(16.dp)) Spacer(Modifier.weight(1f)) HedvigButton( @@ -440,6 +458,7 @@ private fun PreviewInformationScreen() { navigateToNewConversation = {}, openUrl = {}, languageService = previewLanguageService, + onResetOnboardingForDebug = {}, ) } } 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 01edd18b32..052b50f9a2 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 @@ -49,6 +49,7 @@ fun EntryProviderScope.profileEntries( openUrl: (String) -> Unit, navigateToChipId: () -> Unit, languageService: LanguageService, + onResetOnboardingForDebug: () -> Unit, ) { entry(metadata = NavSuiteSceneDecoratorStrategy.showNavBar()) { val viewModel: ProfileViewModel = metroViewModel() @@ -112,6 +113,7 @@ fun EntryProviderScope.profileEntries( hedvigBuildConstants = hedvigBuildConstants, languageService = languageService, openUrl = openUrl, + onResetOnboardingForDebug = onResetOnboardingForDebug, ) } entry(metadata = NavSuiteSceneDecoratorStrategy.showNavBar()) { From 929595ca41a9e59dc785003e132a2681b70e6642 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 22 Jul 2026 14:12:07 +0200 Subject: [PATCH 032/119] Polish onboarding chrome, welcome, consent and payment screens - OnboardingStepButtons: full pill shape (CircleShape clip) on primary; secondary uses HedvigButton(Secondary) instead of HedvigTextButton, with 8dp gap; new secondaryAbovePrimary param (invite screen) - Consistent 16dp top spacer under app bar in all step Content branches (was 8dp in phone, theme, coinsured, petid, invite, bundle, payment) - Welcome: column centered with horizontal 16dp padding; both texts get textAlign = TextAlign.Center - Consent: privacy-policy link replaced with underlined text + 16dp ArrowNorthEast icon in a clickable Row; Allow/Deny rendered as two equal Secondary pill buttons (not primary/secondary split) - Payment: caption and connected-variant hint downgraded to finePrint + textSecondary + 16dp spacing above buttons; checkmark Icon removed - Theme/coinsured/petid: hint text downgraded to finePrint + textSecondary with 16dp spacing above buttons --- .../onboarding/ui/OnboardingStepScaffold.kt | 53 +++++++++---- .../ui/bundle/OnboardingBundleDestination.kt | 2 +- .../OnboardingCoInsuredDestination.kt | 4 +- .../consent/OnboardingConsentDestination.kt | 78 +++++++++++++++---- .../ui/invite/OnboardingInviteDestination.kt | 3 +- .../payment/OnboardingPaymentDestination.kt | 15 ++-- .../ui/petid/OnboardingPetIdDestination.kt | 4 +- .../ui/phone/OnboardingPhoneDestination.kt | 2 +- .../ui/theme/OnboardingThemeDestination.kt | 4 +- .../welcome/OnboardingWelcomeDestination.kt | 15 +++- 10 files changed, 132 insertions(+), 48 deletions(-) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt index 730b258c84..dbcef35721 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt @@ -25,10 +25,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp +import com.hedvig.android.design.system.hedvig.ButtonDefaults import com.hedvig.android.design.system.hedvig.HedvigButton import com.hedvig.android.design.system.hedvig.HedvigPreview import com.hedvig.android.design.system.hedvig.HedvigText -import com.hedvig.android.design.system.hedvig.HedvigTextButton import com.hedvig.android.design.system.hedvig.HedvigTheme import com.hedvig.android.design.system.hedvig.Icon import com.hedvig.android.design.system.hedvig.IconButton @@ -157,7 +157,7 @@ internal fun OnboardingStepHeader(title: String, description: String, modifier: } } -/** Bottom-anchored button area: primary on top, optional ghost secondary below, matching Figma. */ +/** Bottom-anchored button area with full-pill shapes. Primary and optional secondary buttons. */ @Composable internal fun ColumnScope.OnboardingStepButtons( primaryText: String, @@ -165,26 +165,47 @@ internal fun ColumnScope.OnboardingStepButtons( primaryEnabled: Boolean = true, secondaryText: String? = null, onSecondaryClick: (() -> Unit)? = null, + secondaryAbovePrimary: Boolean = false, ) { Spacer(Modifier.height(16.dp)) - HedvigButton( - text = primaryText, - onClick = onPrimaryClick, - enabled = primaryEnabled, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - ) - if (secondaryText != null && onSecondaryClick != null) { - Spacer(Modifier.height(8.dp)) - HedvigTextButton( - text = secondaryText, - onClick = onSecondaryClick, + val primaryButton = @Composable { + HedvigButton( + text = primaryText, + onClick = onPrimaryClick, + enabled = primaryEnabled, modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp), + .padding(horizontal = 16.dp) + .clip(CircleShape), ) } + val secondaryButton: (@Composable () -> Unit)? = if (secondaryText != null && onSecondaryClick != null) { + @Composable { + HedvigButton( + text = secondaryText, + onClick = onSecondaryClick, + enabled = true, + buttonStyle = ButtonDefaults.ButtonStyle.Secondary, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .clip(CircleShape), + ) + } + } else { + null + } + if (secondaryAbovePrimary && secondaryButton != null) { + secondaryButton() + Spacer(Modifier.height(8.dp)) + primaryButton() + } else { + primaryButton() + if (secondaryButton != null) { + Spacer(Modifier.height(8.dp)) + secondaryButton() + } + } Spacer(Modifier.height(16.dp)) Spacer(Modifier.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom))) } diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt index 94486edbc1..e42a6d96c2 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt @@ -130,7 +130,7 @@ internal fun OnboardingBundleDestination( } is OnboardingBundleUiState.Content -> { - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(16.dp)) OnboardingStepHeader( // TODO: Add "Get bundle discount" / "Få paketrabatt" to Lokalise title = "Get bundle discount", diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt index c35123ea27..b90ac5eeeb 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt @@ -180,7 +180,7 @@ internal fun OnboardingCoInsuredDestination(viewModel: OnboardingCoInsuredViewMo } is OnboardingCoInsuredUiState.Content -> { - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(16.dp)) OnboardingStepHeader( // TODO: Add "Add co-insured" / "Lägg till medförsäkrade" to Lokalise title = "Add co-insured", @@ -216,12 +216,14 @@ internal fun OnboardingCoInsuredDestination(viewModel: OnboardingCoInsuredViewMo HedvigText( // TODO: Add "You can add this information later" / "Du kan lägga till den här informationen senare" to Lokalise text = "You can add this information later", + style = HedvigTheme.typography.finePrint, color = HedvigTheme.colorScheme.textSecondary, textAlign = TextAlign.Center, modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), ) + Spacer(Modifier.height(16.dp)) OnboardingStepButtons( // TODO: Add "Continue" / "Fortsätt" to Lokalise primaryText = "Continue", diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingConsentDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingConsentDestination.kt index 1b4d1e2c35..5ce72932a7 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingConsentDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingConsentDestination.kt @@ -1,7 +1,18 @@ package com.hedvig.android.feature.onboarding.ui.consent +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -11,20 +22,27 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.hedvig.android.core.common.di.ActivityRetainedScope import com.hedvig.android.core.common.di.HedvigViewModel import com.hedvig.android.data.settings.datastore.AnalyticsConsent import com.hedvig.android.data.settings.datastore.SettingsDataStore +import com.hedvig.android.design.system.hedvig.ButtonDefaults +import com.hedvig.android.design.system.hedvig.HedvigButton import com.hedvig.android.design.system.hedvig.HedvigErrorSection import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgressDebounced -import com.hedvig.android.design.system.hedvig.HedvigTextButton +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.Icon +import com.hedvig.android.design.system.hedvig.icon.ArrowNorthEast +import com.hedvig.android.design.system.hedvig.icon.HedvigIcons import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId import com.hedvig.android.feature.onboarding.ui.OnboardingProgress -import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons import com.hedvig.android.feature.onboarding.ui.OnboardingStepHeader import com.hedvig.android.feature.onboarding.ui.OnboardingStepScaffold import com.hedvig.android.feature.onboarding.ui.progressFor @@ -133,7 +151,7 @@ internal fun OnboardingConsentDestination( } is OnboardingConsentUiState.Content -> { - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(16.dp)) OnboardingStepHeader( // TODO: Add "Help us make the app better" / "Hjälp oss göra appen bättre" to Lokalise title = "Help us make the app better", @@ -144,19 +162,51 @@ internal fun OnboardingConsentDestination( ) Spacer(Modifier.weight(1f)) // TODO: Add "Privacy policy" / "Integritetspolicy" to Lokalise - HedvigTextButton( - text = "Privacy policy", - onClick = openPrivacyPolicy, - modifier = Modifier.align(Alignment.CenterHorizontally), + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .clip(CircleShape) + .clickable(onClick = openPrivacyPolicy) + .padding(horizontal = 8.dp, vertical = 4.dp), + ) { + HedvigText( + text = "Privacy policy", + style = HedvigTheme.typography.bodySmall, + textDecoration = TextDecoration.Underline, + ) + Icon( + imageVector = HedvigIcons.ArrowNorthEast, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + } + Spacer(Modifier.height(16.dp)) + // TODO: Add "Allow" / "Tillåt" to Lokalise + HedvigButton( + text = "Allow", + onClick = { viewModel.emit(OnboardingConsentEvent.Allow) }, + enabled = true, + buttonStyle = ButtonDefaults.ButtonStyle.Secondary, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .clip(CircleShape), ) - OnboardingStepButtons( - // TODO: Add "Allow" / "Tillåt" to Lokalise - primaryText = "Allow", - onPrimaryClick = { viewModel.emit(OnboardingConsentEvent.Allow) }, - // TODO: Add "Deny" / "Neka" to Lokalise - secondaryText = "Deny", - onSecondaryClick = { viewModel.emit(OnboardingConsentEvent.Deny) }, + Spacer(Modifier.height(8.dp)) + // TODO: Add "Deny" / "Neka" to Lokalise + HedvigButton( + text = "Deny", + onClick = { viewModel.emit(OnboardingConsentEvent.Deny) }, + enabled = true, + buttonStyle = ButtonDefaults.ButtonStyle.Secondary, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .clip(CircleShape), ) + Spacer(Modifier.height(16.dp)) + Spacer(Modifier.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom))) } } } diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt index b09e97a865..8eea1eba9d 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt @@ -130,7 +130,7 @@ internal fun OnboardingInviteDestination(viewModel: OnboardingInviteViewModel, n } is OnboardingInviteUiState.Content -> { - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(16.dp)) OnboardingStepHeader( // TODO: Add "Invite a friend" / "Bjud in en vän" to Lokalise title = "Invite a friend", @@ -149,6 +149,7 @@ internal fun OnboardingInviteDestination(viewModel: OnboardingInviteViewModel, n onPrimaryClick = { viewModel.emit(OnboardingInviteEvent.Continue) }, // TODO: Add "Invite a friend" / "Bjud in en vän" to Lokalise secondaryText = "Invite a friend", + secondaryAbovePrimary = true, onSecondaryClick = { // TODO: Add the share message to Lokalise; align copy with the Forever screen's // REFERRAL_SMS_MESSAGE once product confirms. diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt index dc5a5bd711..28a4df85da 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt @@ -22,9 +22,6 @@ import com.hedvig.android.design.system.hedvig.HedvigErrorSection import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgressDebounced import com.hedvig.android.design.system.hedvig.HedvigText import com.hedvig.android.design.system.hedvig.HedvigTheme -import com.hedvig.android.design.system.hedvig.Icon -import com.hedvig.android.design.system.hedvig.icon.Checkmark -import com.hedvig.android.design.system.hedvig.icon.HedvigIcons import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId @@ -154,7 +151,7 @@ internal fun OnboardingPaymentDestination(viewModel: OnboardingPaymentViewModel, } is OnboardingPaymentUiState.Content -> { - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(16.dp)) if (!content.isConnected) { OnboardingStepHeader( // TODO: Add "Connect payment" / "Koppla betalning" to Lokalise @@ -168,11 +165,13 @@ internal fun OnboardingPaymentDestination(viewModel: OnboardingPaymentViewModel, // "Du behöver lägga till en betalmetod för att hålla din försäkring aktiv" to Lokalise HedvigText( text = "Adding a payment method is required to keep your insurance active", + style = HedvigTheme.typography.finePrint, color = HedvigTheme.colorScheme.textSecondary, modifier = Modifier .align(Alignment.CenterHorizontally) .padding(horizontal = 32.dp), ) + Spacer(Modifier.height(16.dp)) OnboardingStepButtons( // TODO: Add "Connect payment" / "Koppla betalning" to Lokalise primaryText = "Connect payment", @@ -186,19 +185,15 @@ internal fun OnboardingPaymentDestination(viewModel: OnboardingPaymentViewModel, description = "Your payment method is connected", ) Spacer(Modifier.weight(1f)) - Icon( - imageVector = HedvigIcons.Checkmark, - contentDescription = null, - modifier = Modifier.align(Alignment.CenterHorizontally), - ) - Spacer(Modifier.height(16.dp)) // TODO: Add "You can switch accounts later in settings" / // "Du kan byta konto senare i inställningar" to Lokalise HedvigText( text = "You can switch accounts later in settings", + style = HedvigTheme.typography.finePrint, color = HedvigTheme.colorScheme.textSecondary, modifier = Modifier.align(Alignment.CenterHorizontally), ) + Spacer(Modifier.height(16.dp)) OnboardingStepButtons( // TODO: Add "Continue" / "Fortsätt" to Lokalise primaryText = "Continue", diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdDestination.kt index 957304b1b1..f6aa1fa756 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdDestination.kt @@ -180,7 +180,7 @@ internal fun OnboardingPetIdDestination( } is OnboardingPetIdUiState.Content -> { - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(16.dp)) OnboardingStepHeader( // TODO: Add "Add your pets ID numbers" / "Lägg till dina djurs ID-nummer" to Lokalise title = "Add your pets ID numbers", @@ -216,12 +216,14 @@ internal fun OnboardingPetIdDestination( HedvigText( // TODO: Add "You can add this information later" / "Du kan lägga till den här informationen senare" to Lokalise text = "You can add this information later", + style = HedvigTheme.typography.finePrint, color = HedvigTheme.colorScheme.textSecondary, textAlign = TextAlign.Center, modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), ) + Spacer(Modifier.height(16.dp)) OnboardingStepButtons( // TODO: Add "Continue" / "Fortsätt" to Lokalise primaryText = "Continue", diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt index ce429dd919..f4555631d1 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt @@ -172,7 +172,7 @@ internal fun OnboardingPhoneDestination(viewModel: OnboardingPhoneViewModel, nav } is OnboardingPhoneUiState.Content -> { - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(16.dp)) OnboardingStepHeader( // TODO: Add "Phone number" / "Telefonnummer" to Lokalise title = "Phone number", diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt index 0457b9c779..4a731793fb 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt @@ -153,7 +153,7 @@ internal fun OnboardingThemeDestination(viewModel: OnboardingThemeViewModel, nav is OnboardingThemeUiState.Content -> { val content = uiState as OnboardingThemeUiState.Content - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(16.dp)) OnboardingStepHeader( // TODO: Add "Choose theme" / "Välj tema" to Lokalise title = "Choose theme", @@ -189,12 +189,14 @@ internal fun OnboardingThemeDestination(viewModel: OnboardingThemeViewModel, nav HedvigText( // TODO: Add "You can change these settings later" / "Du kan ändra dessa inställningar senare" to Lokalise text = "You can change these settings later", + style = HedvigTheme.typography.finePrint, color = HedvigTheme.colorScheme.textSecondary, textAlign = TextAlign.Center, modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), ) + Spacer(Modifier.height(16.dp)) OnboardingStepButtons( // TODO: Add "Continue" / "Fortsätt" to Lokalise primaryText = "Continue", diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt index e0c060fcf8..f35d1f3289 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -16,6 +17,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.hedvig.android.core.common.di.ActivityRetainedScope @@ -118,7 +120,12 @@ internal fun OnboardingWelcomeDestination(viewModel: OnboardingWelcomeViewModel) is OnboardingWelcomeUiState.Content -> { Spacer(Modifier.weight(1f)) - Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { // Static stand-in for the Figma app-icon-with-badge graphic; animation is a follow-up. Box( Modifier @@ -127,12 +134,16 @@ internal fun OnboardingWelcomeDestination(viewModel: OnboardingWelcomeViewModel) ) Spacer(Modifier.height(24.dp)) // TODO: Add "Welcome to Hedvig" / "Välkommen till Hedvig" to Lokalise - HedvigText("Welcome to Hedvig") + HedvigText( + text = "Welcome to Hedvig", + textAlign = TextAlign.Center, + ) // TODO: Add "Follow the steps to get started with your new insurance" / // "Följ stegen för att komma igång med din nya försäkring" to Lokalise HedvigText( text = "Follow the steps to get started with your new insurance", color = HedvigTheme.colorScheme.textSecondary, + textAlign = TextAlign.Center, ) } Spacer(Modifier.weight(1f)) From 33d06c0c06c1cc1d305c88200a3264f80fab6b69 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 22 Jul 2026 14:19:37 +0200 Subject: [PATCH 033/119] Rework onboarding phone input and theme picker Phone screen: migrate to TextFieldState-based HedvigTextField, remove UpdatePhoneNumber event, replace with Save(phoneNumber) data class and ClearSubmissionError object, and reorder layout to header-weight-field-buttons. Theme screen: replace custom ThemeOptionRow with design-system RadioGroup, using theme name as RadioOptionId and the existing SETTINGS_THEME_* string resources as option text. --- .../ui/phone/OnboardingPhoneDestination.kt | 43 +++++---- .../ui/theme/OnboardingThemeDestination.kt | 88 +++++++------------ .../ui/phone/OnboardingPhonePresenterTest.kt | 6 +- 3 files changed, 58 insertions(+), 79 deletions(-) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt index f4555631d1..a6a1855876 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -12,6 +13,7 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp @@ -35,6 +37,7 @@ import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel import dev.zacsweers.metro.Inject +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch @Inject @@ -60,6 +63,7 @@ internal class OnboardingPhonePresenter( var currentState by remember { mutableStateOf(lastState) } var loadIteration by remember { mutableIntStateOf(0) } var submitIteration by remember { mutableIntStateOf(0) } + var phoneNumberToSubmit by remember { mutableStateOf("") } LaunchedEffect(loadIteration) { if (currentState is OnboardingPhoneUiState.Content) return@LaunchedEffect @@ -82,10 +86,9 @@ internal class OnboardingPhonePresenter( currentState = content.copy(isSubmitting = true, showSubmissionError = false) onboardingRepository.updateContactInfo( email = session.data.email, - phoneNumber = content.phoneNumber, + phoneNumber = phoneNumberToSubmit, ).fold( ifLeft = { - // Derive from the live state so digits typed while submitting are not lost. currentState = (currentState as? OnboardingPhoneUiState.Content ?: content) .copy(isSubmitting = false, showSubmissionError = true) }, @@ -107,18 +110,19 @@ internal class OnboardingPhonePresenter( launch { navigator.exitOnboarding() } } - is OnboardingPhoneEvent.UpdatePhoneNumber -> { - val content = currentState as? OnboardingPhoneUiState.Content ?: return@CollectEvents - currentState = content.copy(phoneNumber = event.phoneNumber, showSubmissionError = false) - } - - OnboardingPhoneEvent.Save -> { + is OnboardingPhoneEvent.Save -> { + phoneNumberToSubmit = event.phoneNumber submitIteration++ } OnboardingPhoneEvent.DoThisLater -> { launch { navigator.continueFrom(OnboardingStepId.PhoneNumber) } } + + OnboardingPhoneEvent.ClearSubmissionError -> { + val content = currentState as? OnboardingPhoneUiState.Content ?: return@CollectEvents + currentState = content.copy(showSubmissionError = false) + } } } @@ -144,11 +148,11 @@ internal sealed interface OnboardingPhoneEvent { data object Close : OnboardingPhoneEvent - data class UpdatePhoneNumber(val phoneNumber: String) : OnboardingPhoneEvent - - data object Save : OnboardingPhoneEvent + data class Save(val phoneNumber: String) : OnboardingPhoneEvent data object DoThisLater : OnboardingPhoneEvent + + data object ClearSubmissionError : OnboardingPhoneEvent } @Composable @@ -172,6 +176,12 @@ internal fun OnboardingPhoneDestination(viewModel: OnboardingPhoneViewModel, nav } is OnboardingPhoneUiState.Content -> { + val phoneNumberState = rememberTextFieldState(content.phoneNumber) + LaunchedEffect(phoneNumberState) { + snapshotFlow { phoneNumberState.text.toString() } + .drop(1) + .collect { viewModel.emit(OnboardingPhoneEvent.ClearSubmissionError) } + } Spacer(Modifier.height(16.dp)) OnboardingStepHeader( // TODO: Add "Phone number" / "Telefonnummer" to Lokalise @@ -180,10 +190,9 @@ internal fun OnboardingPhoneDestination(viewModel: OnboardingPhoneViewModel, nav // "Lägg till ditt telefonnummer så att vi kan nå dig om något händer" to Lokalise description = "Add your phone number so we can reach you if something happens", ) - Spacer(Modifier.height(16.dp)) + Spacer(Modifier.weight(1f)) HedvigTextField( - text = content.phoneNumber, - onValueChange = { viewModel.emit(OnboardingPhoneEvent.UpdatePhoneNumber(it)) }, + state = phoneNumberState, // TODO: Add "Phone number" / "Telefonnummer" to Lokalise labelText = "Phone number", textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, @@ -198,12 +207,12 @@ internal fun OnboardingPhoneDestination(viewModel: OnboardingPhoneViewModel, nav .fillMaxWidth() .padding(horizontal = 16.dp), ) - Spacer(Modifier.weight(1f)) + Spacer(Modifier.height(16.dp)) OnboardingStepButtons( // TODO: Add "Save" / "Spara" to Lokalise primaryText = "Save", - onPrimaryClick = { viewModel.emit(OnboardingPhoneEvent.Save) }, - primaryEnabled = !content.isSubmitting && content.phoneNumber.isNotBlank(), + onPrimaryClick = { viewModel.emit(OnboardingPhoneEvent.Save(phoneNumberState.text.toString())) }, + primaryEnabled = !content.isSubmitting && phoneNumberState.text.isNotBlank(), // TODO: Add "Do this later" / "Gör det senare" to Lokalise secondaryText = "Do this later", onSecondaryClick = { viewModel.emit(OnboardingPhoneEvent.DoThisLater) }, diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt index 4a731793fb..f1c0b2220b 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt @@ -1,8 +1,5 @@ package com.hedvig.android.feature.onboarding.ui.theme -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -15,9 +12,7 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -28,9 +23,9 @@ import com.hedvig.android.design.system.hedvig.HedvigErrorSection import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgressDebounced import com.hedvig.android.design.system.hedvig.HedvigText import com.hedvig.android.design.system.hedvig.HedvigTheme -import com.hedvig.android.design.system.hedvig.Icon -import com.hedvig.android.design.system.hedvig.icon.Checkmark -import com.hedvig.android.design.system.hedvig.icon.HedvigIcons +import com.hedvig.android.design.system.hedvig.RadioGroup +import com.hedvig.android.design.system.hedvig.RadioOption +import com.hedvig.android.design.system.hedvig.RadioOptionId import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId @@ -160,30 +155,33 @@ internal fun OnboardingThemeDestination(viewModel: OnboardingThemeViewModel, nav // TODO: Add "Customize the look of the app" / "Anpassa utseendet på appen" to Lokalise description = "Customize the look of the app", ) - Spacer(Modifier.height(16.dp)) - ThemeOptionRow( - title = stringResource(Res.string.SETTINGS_THEME_SYSTEM_DEFAULT), - // TODO: Add "Uses your phone's setting" / "Använder din telefons inställning" to Lokalise - subtitle = "Uses your phone's setting", - isSelected = content.selectedTheme == Theme.SYSTEM_DEFAULT, - onClick = { viewModel.emit(OnboardingThemeEvent.SelectTheme(Theme.SYSTEM_DEFAULT)) }, - modifier = Modifier.padding(horizontal = 16.dp), - ) - ThemeOptionRow( - title = stringResource(Res.string.SETTINGS_THEME_LIGHT), - // TODO: Add "Set light mode" / "Ange ljust läge" to Lokalise - subtitle = "Set light mode", - isSelected = content.selectedTheme == Theme.LIGHT, - onClick = { viewModel.emit(OnboardingThemeEvent.SelectTheme(Theme.LIGHT)) }, - modifier = Modifier.padding(horizontal = 16.dp), - ) - ThemeOptionRow( - title = stringResource(Res.string.SETTINGS_THEME_DARK), - // TODO: Add "Set dark mode" / "Ange mörkt läge" to Lokalise - subtitle = "Set dark mode", - isSelected = content.selectedTheme == Theme.DARK, - onClick = { viewModel.emit(OnboardingThemeEvent.SelectTheme(Theme.DARK)) }, - modifier = Modifier.padding(horizontal = 16.dp), + Spacer(Modifier.weight(1f)) + RadioGroup( + options = listOf( + RadioOption( + id = RadioOptionId(Theme.SYSTEM_DEFAULT.name), + text = stringResource(Res.string.SETTINGS_THEME_SYSTEM_DEFAULT), + // TODO: Add "Uses your phone's setting" / "Använder din telefons inställning" to Lokalise + label = "Uses your phone's setting", + ), + RadioOption( + id = RadioOptionId(Theme.LIGHT.name), + text = stringResource(Res.string.SETTINGS_THEME_LIGHT), + // TODO: Add "Set light mode" / "Ange ljust läge" to Lokalise + label = "Set light mode", + ), + RadioOption( + id = RadioOptionId(Theme.DARK.name), + text = stringResource(Res.string.SETTINGS_THEME_DARK), + // TODO: Add "Set dark mode" / "Ange mörkt läge" to Lokalise + label = "Set dark mode", + ), + ), + selectedOption = RadioOptionId(content.selectedTheme.name), + onRadioOptionSelected = { viewModel.emit(OnboardingThemeEvent.SelectTheme(Theme.valueOf(it.id))) }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), ) Spacer(Modifier.weight(1f)) HedvigText( @@ -206,29 +204,3 @@ internal fun OnboardingThemeDestination(viewModel: OnboardingThemeViewModel, nav } } } - -@Composable -private fun ThemeOptionRow( - title: String, - subtitle: String, - isSelected: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = modifier - .fillMaxWidth() - .clip(HedvigTheme.shapes.cornerMedium) - .clickable(onClick = onClick) - .padding(vertical = 16.dp), - ) { - Column(Modifier.weight(1f)) { - HedvigText(title) - HedvigText(subtitle, color = HedvigTheme.colorScheme.textSecondary) - } - if (isSelected) { - Icon(imageVector = HedvigIcons.Checkmark, contentDescription = null) - } - } -} diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhonePresenterTest.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhonePresenterTest.kt index ed1cbf626f..649e8c9818 100644 --- a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhonePresenterTest.kt +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhonePresenterTest.kt @@ -66,11 +66,9 @@ internal class OnboardingPhonePresenterTest { skipItems(1) repository.onboardingDataResponses.add(testOnboardingData().right()) awaitItem() - sendEvent(OnboardingPhoneEvent.UpdatePhoneNumber("0701234567")) - sendEvent(OnboardingPhoneEvent.Save) + sendEvent(OnboardingPhoneEvent.Save("0701234567")) runCurrent() repository.updateContactInfoResponses.add(Unit.right()) - awaitItem() // phone number updated, not yet submitting awaitItem() // isSubmitting = true; continueFrom already ran during runCurrent() above val settled = awaitItem() // isSubmitting reset to false before navigating away assertThat(settled).isInstanceOf() @@ -91,7 +89,7 @@ internal class OnboardingPhonePresenterTest { skipItems(1) repository.onboardingDataResponses.add(testOnboardingData().right()) awaitItem() - sendEvent(OnboardingPhoneEvent.Save) + sendEvent(OnboardingPhoneEvent.Save("0701234567")) runCurrent() repository.updateContactInfoResponses.add(ErrorMessage("nope").left()) awaitItem() // isSubmitting = true From 52b02b63f1eaf12db88f2bad77790e5f2a815957 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 22 Jul 2026 14:31:06 +0200 Subject: [PATCH 034/119] Style onboarding co-insured and pet id rows as pillow cards Replace plain text rows with rounded surfacePrimary HedvigCard rows: each card shows a 40dp pillow image (from typeOfContract), contract display name and exposure name in bodySmall, and a pill Primary Add button (or a checkmark when isComplete). The card stack is vertically centered between the header and the hint/buttons via weight spacers. Add shared OnboardingContractCard composable, add typeOfContract field to CoInsuredRow/PetIdRow, add dataContract dependency to the module, and update presenter tests to assert typeOfContract on rows. --- .../feature-onboarding/build.gradle.kts | 1 + .../onboarding/ui/OnboardingContractCard.kt | 82 +++++++++++++++++++ .../OnboardingCoInsuredDestination.kt | 44 ++++------ .../ui/petid/OnboardingPetIdDestination.kt | 44 ++++------ .../OnboardingCoInsuredPresenterTest.kt | 2 + .../ui/petid/OnboardingPetIdPresenterTest.kt | 2 + 6 files changed, 115 insertions(+), 60 deletions(-) create mode 100644 app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingContractCard.kt diff --git a/app/feature/feature-onboarding/build.gradle.kts b/app/feature/feature-onboarding/build.gradle.kts index 7efc1dca75..2a23d0f9e8 100644 --- a/app/feature/feature-onboarding/build.gradle.kts +++ b/app/feature/feature-onboarding/build.gradle.kts @@ -30,6 +30,7 @@ dependencies { implementation(projects.authCorePublic) implementation(projects.coreCommonPublic) implementation(projects.coreResources) + implementation(projects.dataContract) implementation(projects.dataSettingsDatastorePublic) implementation(projects.designSystemHedvig) implementation(projects.dataCoinsured) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingContractCard.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingContractCard.kt new file mode 100644 index 0000000000..a5c017c11a --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingContractCard.kt @@ -0,0 +1,82 @@ +package com.hedvig.android.feature.onboarding.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import com.hedvig.android.data.contract.pillowResource +import com.hedvig.android.data.contract.toContractGroup +import com.hedvig.android.design.system.hedvig.ButtonDefaults +import com.hedvig.android.design.system.hedvig.HedvigButton +import com.hedvig.android.design.system.hedvig.HedvigCard +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.Icon +import com.hedvig.android.design.system.hedvig.icon.Checkmark +import com.hedvig.android.design.system.hedvig.icon.HedvigIcons +import org.jetbrains.compose.resources.painterResource + +@Composable +internal fun OnboardingContractCard( + displayName: String, + exposureName: String, + typeOfContract: String, + isComplete: Boolean, + onAddClick: () -> Unit, + modifier: Modifier = Modifier, +) { + HedvigCard( + shape = HedvigTheme.shapes.cornerLarge, + color = HedvigTheme.colorScheme.surfacePrimary, + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) { + Image( + painter = painterResource(typeOfContract.toContractGroup().pillowResource()), + contentDescription = null, + modifier = Modifier.size(40.dp), + ) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + HedvigText( + text = displayName, + style = HedvigTheme.typography.bodySmall, + ) + HedvigText( + text = exposureName, + style = HedvigTheme.typography.bodySmall, + color = HedvigTheme.colorScheme.textSecondary, + ) + } + Spacer(Modifier.width(12.dp)) + if (isComplete) { + Icon(imageVector = HedvigIcons.Checkmark, contentDescription = null) + } else { + HedvigButton( + // TODO: Add "Add" / "Lägg till" to Lokalise + text = "Add", + onClick = onAddClick, + enabled = true, + buttonStyle = ButtonDefaults.ButtonStyle.Primary, + buttonSize = ButtonDefaults.ButtonSize.Small, + modifier = Modifier.clip(CircleShape), + ) + } + } + } +} diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt index b90ac5eeeb..eaa44a207c 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt @@ -1,7 +1,7 @@ package com.hedvig.android.feature.onboarding.ui.coinsured +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -14,7 +14,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -22,19 +21,15 @@ import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.hedvig.android.core.common.di.ActivityRetainedScope import com.hedvig.android.core.common.di.HedvigViewModel -import com.hedvig.android.design.system.hedvig.ButtonDefaults -import com.hedvig.android.design.system.hedvig.HedvigButton import com.hedvig.android.design.system.hedvig.HedvigErrorSection import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgressDebounced import com.hedvig.android.design.system.hedvig.HedvigText import com.hedvig.android.design.system.hedvig.HedvigTheme -import com.hedvig.android.design.system.hedvig.Icon -import com.hedvig.android.design.system.hedvig.icon.Checkmark -import com.hedvig.android.design.system.hedvig.icon.HedvigIcons import com.hedvig.android.feature.onboarding.data.OnboardingSession import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId +import com.hedvig.android.feature.onboarding.ui.OnboardingContractCard import com.hedvig.android.feature.onboarding.ui.OnboardingProgress import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons import com.hedvig.android.feature.onboarding.ui.OnboardingStepHeader @@ -82,6 +77,7 @@ internal class OnboardingCoInsuredPresenter( contractId = contract.id, displayName = contract.displayName, exposureName = contract.exposureName, + typeOfContract = contract.typeOfContract, isComplete = contract.missingCoInsuredCount == 0, ) }, @@ -122,6 +118,7 @@ internal data class CoInsuredRow( val contractId: String, val displayName: String, val exposureName: String, + val typeOfContract: String, val isComplete: Boolean, ) @@ -187,29 +184,16 @@ internal fun OnboardingCoInsuredDestination(viewModel: OnboardingCoInsuredViewMo // TODO: Add "So we know who's covered by your insurance" / "Så att vi vet vem som är försäkrad" to Lokalise description = "So we know who's covered by your insurance", ) - Spacer(Modifier.height(16.dp)) - for (row in content.rows) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - ) { - Column(Modifier.weight(1f)) { - HedvigText(row.displayName) - HedvigText(row.exposureName, color = HedvigTheme.colorScheme.textSecondary) - } - if (row.isComplete) { - Icon(imageVector = HedvigIcons.Checkmark, contentDescription = null) - } else { - HedvigButton( - // TODO: Add "Add" / "Lägg till" to Lokalise - text = "Add", - onClick = { viewModel.emit(OnboardingCoInsuredEvent.AddCoInsured(row.contractId)) }, - enabled = true, - buttonSize = ButtonDefaults.ButtonSize.Small, - ) - } + Spacer(Modifier.weight(1f)) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + for (row in content.rows) { + OnboardingContractCard( + displayName = row.displayName, + exposureName = row.exposureName, + typeOfContract = row.typeOfContract, + isComplete = row.isComplete, + onAddClick = { viewModel.emit(OnboardingCoInsuredEvent.AddCoInsured(row.contractId)) }, + ) } } Spacer(Modifier.weight(1f)) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdDestination.kt index f6aa1fa756..6b13cb7ce7 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdDestination.kt @@ -1,7 +1,7 @@ package com.hedvig.android.feature.onboarding.ui.petid +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -14,7 +14,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -22,19 +21,15 @@ import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.hedvig.android.core.common.di.ActivityRetainedScope import com.hedvig.android.core.common.di.HedvigViewModel -import com.hedvig.android.design.system.hedvig.ButtonDefaults -import com.hedvig.android.design.system.hedvig.HedvigButton import com.hedvig.android.design.system.hedvig.HedvigErrorSection import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgressDebounced import com.hedvig.android.design.system.hedvig.HedvigText import com.hedvig.android.design.system.hedvig.HedvigTheme -import com.hedvig.android.design.system.hedvig.Icon -import com.hedvig.android.design.system.hedvig.icon.Checkmark -import com.hedvig.android.design.system.hedvig.icon.HedvigIcons import com.hedvig.android.feature.onboarding.data.OnboardingSession import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId +import com.hedvig.android.feature.onboarding.ui.OnboardingContractCard import com.hedvig.android.feature.onboarding.ui.OnboardingProgress import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons import com.hedvig.android.feature.onboarding.ui.OnboardingStepHeader @@ -82,6 +77,7 @@ internal class OnboardingPetIdPresenter( contractId = contract.id, displayName = contract.displayName, exposureName = contract.exposureName, + typeOfContract = contract.typeOfContract, isComplete = !contract.isMissingPetId, ) }, @@ -120,6 +116,7 @@ internal data class PetIdRow( val contractId: String, val displayName: String, val exposureName: String, + val typeOfContract: String, val isComplete: Boolean, ) @@ -187,29 +184,16 @@ internal fun OnboardingPetIdDestination( // TODO: Add "This makes it easier to help you if something happens" / "Det gör det lättare att hjälpa dig om något händer" to Lokalise description = "This makes it easier to help you if something happens", ) - Spacer(Modifier.height(16.dp)) - for (row in content.rows) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - ) { - Column(Modifier.weight(1f)) { - HedvigText(row.displayName) - HedvigText(row.exposureName, color = HedvigTheme.colorScheme.textSecondary) - } - if (row.isComplete) { - Icon(imageVector = HedvigIcons.Checkmark, contentDescription = null) - } else { - HedvigButton( - // TODO: Add "Add" / "Lägg till" to Lokalise - text = "Add", - onClick = { onAddPetId(row.contractId) }, - enabled = true, - buttonSize = ButtonDefaults.ButtonSize.Small, - ) - } + Spacer(Modifier.weight(1f)) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + for (row in content.rows) { + OnboardingContractCard( + displayName = row.displayName, + exposureName = row.exposureName, + typeOfContract = row.typeOfContract, + isComplete = row.isComplete, + onAddClick = { onAddPetId(row.contractId) }, + ) } } Spacer(Modifier.weight(1f)) diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredPresenterTest.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredPresenterTest.kt index df7c942910..96f8638613 100644 --- a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredPresenterTest.kt +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredPresenterTest.kt @@ -54,6 +54,7 @@ internal class OnboardingCoInsuredPresenterTest { assertThat(state).isInstanceOf() val content = state as OnboardingCoInsuredUiState.Content assertThat(content.rows.size).isEqualTo(1) + assertThat(content.rows[0].typeOfContract).isEqualTo("SE_APARTMENT_RENT") assertThat(content.rows[0].isComplete).isEqualTo(false) } } @@ -108,6 +109,7 @@ internal class OnboardingCoInsuredPresenterTest { assertThat(state).isInstanceOf() val content = state as OnboardingCoInsuredUiState.Content assertThat(content.rows.size).isEqualTo(1) + assertThat(content.rows[0].typeOfContract).isEqualTo("SE_APARTMENT_RENT") assertThat(content.rows[0].isComplete).isEqualTo(true) } } diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdPresenterTest.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdPresenterTest.kt index 7c0515c3b8..1ad9db686e 100644 --- a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdPresenterTest.kt +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdPresenterTest.kt @@ -63,6 +63,7 @@ internal class OnboardingPetIdPresenterTest { assertThat(state).isInstanceOf() val content = state as OnboardingPetIdUiState.Content assertThat(content.rows.size).isEqualTo(1) + assertThat(content.rows[0].typeOfContract).isEqualTo("SE_APARTMENT_RENT") assertThat(content.rows[0].isComplete).isEqualTo(false) } } @@ -112,6 +113,7 @@ internal class OnboardingPetIdPresenterTest { assertThat(state).isInstanceOf() val content = state as OnboardingPetIdUiState.Content assertThat(content.rows.size).isEqualTo(1) + assertThat(content.rows[0].typeOfContract).isEqualTo("SE_APARTMENT_RENT") assertThat(content.rows[0].isComplete).isEqualTo(true) } } From 9c4e3dae0b19da7a80f6845587d6ddf6d8049dbf Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 22 Jul 2026 14:43:41 +0200 Subject: [PATCH 035/119] Show example referrals and open Forever from onboarding invite step --- .../feature-onboarding/build.gradle.kts | 1 + .../navigation/OnboardingEntries.kt | 7 ++ .../onboarding/navigation/OnboardingKeys.kt | 9 +++ .../navigation/OnboardingNavigator.kt | 5 ++ .../ui/invite/OnboardingInviteDestination.kt | 77 ++++++++++++------- .../invite/OnboardingInvitePresenterTest.kt | 22 +++++- 6 files changed, 92 insertions(+), 29 deletions(-) diff --git a/app/feature/feature-onboarding/build.gradle.kts b/app/feature/feature-onboarding/build.gradle.kts index 2a23d0f9e8..2a975444f6 100644 --- a/app/feature/feature-onboarding/build.gradle.kts +++ b/app/feature/feature-onboarding/build.gradle.kts @@ -36,6 +36,7 @@ dependencies { implementation(projects.dataCoinsured) implementation(projects.featureConnectPaymentTrustlyNavigation) implementation(projects.featureEditCoinsuredNavigation) + implementation(projects.foreverUi) implementation(projects.moleculePublic) implementation(projects.navigationCommon) implementation(projects.navigationCompose) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingEntries.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingEntries.kt index be16cb1051..d37ab7a146 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingEntries.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingEntries.kt @@ -21,6 +21,8 @@ import com.hedvig.android.feature.onboarding.ui.welcome.OnboardingWelcomeDestina import com.hedvig.android.feature.onboarding.ui.welcome.OnboardingWelcomeViewModel import com.hedvig.android.navigation.common.HedvigNavKey import com.hedvig.android.navigation.compose.Backstack +import com.hedvig.android.shared.foreverui.ui.ui.ForeverDestination +import com.hedvig.android.shared.foreverui.ui.ui.ForeverViewModel import dev.zacsweers.metrox.viewmodel.metroViewModel fun EntryProviderScope.onboardingEntries( @@ -34,6 +36,11 @@ fun EntryProviderScope.onboardingEntries( OnboardingWelcomeDestination(viewModel) } + entry { + val viewModel: ForeverViewModel = metroViewModel() + ForeverDestination(viewModel) + } + entry { key -> when (key.stepId) { OnboardingStepId.AnalyticsConsent -> { diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingKeys.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingKeys.kt index d3accba5f6..a2b4fbf343 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingKeys.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingKeys.kt @@ -22,6 +22,15 @@ internal data class OnboardingStepKey( val stepId: OnboardingStepId, ) : HedvigNavKey +/** + * Hosts the shared Forever screen inside the onboarding flow, so the member can invite friends + * without leaving onboarding. Reached from the invite step's "Invite a friend" button and left with + * system back, which pops back to the invite step still sitting in the back stack. + */ +@androidx.annotation.Keep +@Serializable +internal data object OnboardingForeverKey : HedvigNavKey + /** * Identifies each onboarding step past welcome. The member's concrete path (which of these apply, * in order) is computed by buildOnboardingPath from the eagerly fetched OnboardingData. diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingNavigator.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingNavigator.kt index c7319f99aa..29c13bf33d 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingNavigator.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingNavigator.kt @@ -61,4 +61,9 @@ internal class OnboardingNavigator( fun openConnectPayment() { backstack.add(TrustlyKey) } + + /** Pushes the shared Forever screen; the member leaves it with system back. */ + fun openForeverScreen() { + backstack.add(OnboardingForeverKey) + } } diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt index 8eea1eba9d..b5a4ecd9ef 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt @@ -1,9 +1,15 @@ package com.hedvig.android.feature.onboarding.ui.invite -import android.content.Context -import android.content.Intent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -13,7 +19,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.hedvig.android.core.common.di.ActivityRetainedScope @@ -21,6 +27,9 @@ import com.hedvig.android.core.common.di.HedvigViewModel import com.hedvig.android.design.system.hedvig.HedvigErrorSection import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgressDebounced import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.HorizontalDivider +import com.hedvig.android.design.system.hedvig.Surface import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId @@ -68,7 +77,6 @@ internal class OnboardingInvitePresenter( } else { OnboardingInviteUiState.Content( progress = session.progressFor(OnboardingStepId.InviteFriend), - code = referral.code, incentiveDisplay = "${referral.monthlyDiscountPerReferralAmount.toInt()} ${referral.currencyCode}", ) } @@ -81,6 +89,7 @@ internal class OnboardingInvitePresenter( OnboardingInviteEvent.Retry -> loadIteration++ OnboardingInviteEvent.Close -> launch { navigator.exitOnboarding() } OnboardingInviteEvent.Continue -> launch { navigator.continueFrom(OnboardingStepId.InviteFriend) } + OnboardingInviteEvent.InviteFriend -> navigator.openForeverScreen() } } @@ -95,7 +104,6 @@ internal sealed interface OnboardingInviteUiState { data class Content( val progress: OnboardingProgress, - val code: String, val incentiveDisplay: String, ) : OnboardingInviteUiState } @@ -106,12 +114,13 @@ internal sealed interface OnboardingInviteEvent { data object Close : OnboardingInviteEvent data object Continue : OnboardingInviteEvent + + data object InviteFriend : OnboardingInviteEvent } @Composable internal fun OnboardingInviteDestination(viewModel: OnboardingInviteViewModel, navigateUp: () -> Unit) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val context = LocalContext.current OnboardingStepScaffold( progress = (uiState as? OnboardingInviteUiState.Content)?.progress, showBackButton = true, @@ -138,8 +147,8 @@ internal fun OnboardingInviteDestination(viewModel: OnboardingInviteViewModel, n description = "With Hedvig Forever, you get ${state.incentiveDisplay} off for every friend you invite", ) Spacer(Modifier.weight(1f)) - HedvigText( - text = state.code, + ExampleReferralsCard( + incentiveDisplay = state.incentiveDisplay, modifier = Modifier.align(Alignment.CenterHorizontally), ) Spacer(Modifier.weight(1f)) @@ -150,29 +159,43 @@ internal fun OnboardingInviteDestination(viewModel: OnboardingInviteViewModel, n // TODO: Add "Invite a friend" / "Bjud in en vän" to Lokalise secondaryText = "Invite a friend", secondaryAbovePrimary = true, - onSecondaryClick = { - // TODO: Add the share message to Lokalise; align copy with the Forever screen's - // REFERRAL_SMS_MESSAGE once product confirms. - context.showShareSheet( - title = "Invite a friend", - text = "With my code you get a discount at Hedvig. Use code ${state.code}.", - ) - }, + onSecondaryClick = { viewModel.emit(OnboardingInviteEvent.InviteFriend) }, ) } } } } -private fun Context.showShareSheet(title: String, text: String) { - startActivity( - Intent.createChooser( - Intent().apply { - action = Intent.ACTION_SEND - putExtra(Intent.EXTRA_TEXT, text) - type = "text/plain" - }, - title, - ), - ) +@Composable +private fun ExampleReferralsCard(incentiveDisplay: String, modifier: Modifier = Modifier) { + // Illustrative, hardcoded example names showing what the referral list looks like once populated. + val exampleNames = listOf("Hampus", "Li", "Elin") + Surface( + modifier = modifier, + shape = HedvigTheme.shapes.cornerLarge, + color = HedvigTheme.colorScheme.surfacePrimary, + ) { + Column(Modifier.padding(horizontal = 24.dp, vertical = 16.dp)) { + exampleNames.forEachIndexed { index, name -> + if (index > 0) { + HorizontalDivider() + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 12.dp), + ) { + Box( + Modifier + .size(8.dp) + .clip(CircleShape) + .background(HedvigTheme.colorScheme.signalGreenElement), + ) + Spacer(Modifier.width(8.dp)) + HedvigText(text = name, style = HedvigTheme.typography.bodySmall) + Spacer(Modifier.width(32.dp).weight(1f)) + HedvigText(text = "-$incentiveDisplay", style = HedvigTheme.typography.bodySmall) + } + } + } + } } diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInvitePresenterTest.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInvitePresenterTest.kt index 75393847b7..e3690a6ea7 100644 --- a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInvitePresenterTest.kt +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInvitePresenterTest.kt @@ -10,6 +10,7 @@ import com.hedvig.android.feature.onboarding.FakeOnboardingRepository import com.hedvig.android.feature.onboarding.data.CompleteOnboardingUseCase import com.hedvig.android.feature.onboarding.data.OnboardingReferralInformation import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore +import com.hedvig.android.feature.onboarding.navigation.OnboardingForeverKey import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId import com.hedvig.android.feature.onboarding.navigation.OnboardingStepKey @@ -36,7 +37,7 @@ internal class OnboardingInvitePresenterTest { } @Test - fun `content carries the referral code and incentive`() = runTest { + fun `content carries the incentive`() = runTest { val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.InviteFriend)) } val repository = FakeOnboardingRepository() val sessionStore = OnboardingSessionStore(repository, FakeOnboardingMemberIdProvider()) @@ -49,11 +50,28 @@ internal class OnboardingInvitePresenterTest { val state = awaitItem() assertThat(state).isInstanceOf() val content = state as OnboardingInviteUiState.Content - assertThat(content.code).isEqualTo("CODE") assertThat(content.incentiveDisplay).isEqualTo("10 SEK") } } + @Test + fun `invite friend pushes the standalone forever screen`() = runTest { + val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.InviteFriend)) } + val repository = FakeOnboardingRepository() + val sessionStore = OnboardingSessionStore(repository, FakeOnboardingMemberIdProvider()) + val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) + val presenter = OnboardingInvitePresenter(sessionStore, navigator) + + presenter.test(OnboardingInviteUiState.Loading) { + skipItems(1) + repository.onboardingDataResponses.add(testOnboardingData().right()) + assertThat(awaitItem()).isInstanceOf() + sendEvent(OnboardingInviteEvent.InviteFriend) + runCurrent() + assertThat(backstack.entries.last()).isEqualTo(OnboardingForeverKey) + } + } + @Test fun `continue advances to connect payment`() = runTest { val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.InviteFriend)) } From 1e88502d0607c7974080de031459e4cd7d692214 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 22 Jul 2026 14:44:35 +0200 Subject: [PATCH 036/119] Show cross-sell pillows on the onboarding bundle step --- .../app/navigation/HedvigEntryProvider.kt | 1 + .../feature-onboarding/build.gradle.kts | 2 + .../src/main/graphql/OnboardingQuery.graphql | 3 + .../feature/onboarding/data/OnboardingData.kt | 1 + .../onboarding/data/OnboardingRepository.kt | 1 + .../navigation/OnboardingEntries.kt | 4 +- .../ui/bundle/OnboardingBundleDestination.kt | 84 +++++++++++++++---- .../onboarding/FakeOnboardingRepository.kt | 10 ++- .../onboarding/data/OnboardingPathTest.kt | 10 ++- 9 files changed, 95 insertions(+), 21 deletions(-) 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 a240ffc81a..e6f95d54ad 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 @@ -557,6 +557,7 @@ private fun EntryProviderScope.addSharedFlowEntries( removeAddonsEntries(backstack = backstack) onboardingEntries( backstack = backstack, + imageLoader = imageLoader, openUrl = openUrl, openPrivacyPolicy = openPrivacyPolicy, navigateToChipId = { contractId -> backstack.add(ChipIdKey(contractId)) }, diff --git a/app/feature/feature-onboarding/build.gradle.kts b/app/feature/feature-onboarding/build.gradle.kts index 2a975444f6..6d5270e1cc 100644 --- a/app/feature/feature-onboarding/build.gradle.kts +++ b/app/feature/feature-onboarding/build.gradle.kts @@ -30,6 +30,8 @@ dependencies { implementation(projects.authCorePublic) implementation(projects.coreCommonPublic) implementation(projects.coreResources) + implementation(libs.coil.coil) + implementation(libs.coil.compose) implementation(projects.dataContract) implementation(projects.dataSettingsDatastorePublic) implementation(projects.designSystemHedvig) diff --git a/app/feature/feature-onboarding/src/main/graphql/OnboardingQuery.graphql b/app/feature/feature-onboarding/src/main/graphql/OnboardingQuery.graphql index e0e07d79ff..aac5807757 100644 --- a/app/feature/feature-onboarding/src/main/graphql/OnboardingQuery.graphql +++ b/app/feature/feature-onboarding/src/main/graphql/OnboardingQuery.graphql @@ -35,6 +35,9 @@ query Onboarding { title description storeUrl + pillowImageLarge { + src + } } } } diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingData.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingData.kt index f1ca451c87..1c49a221bc 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingData.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingData.kt @@ -42,4 +42,5 @@ internal data class OnboardingCrossSell( val title: String, val description: String, val storeUrl: String, + val pillowImageUrl: String?, ) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingRepository.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingRepository.kt index 44b75c9ca1..f946cc1c0b 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingRepository.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingRepository.kt @@ -60,6 +60,7 @@ internal class OnboardingRepositoryImpl( title = crossSell.title, description = crossSell.description, storeUrl = crossSell.storeUrl, + pillowImageUrl = crossSell.pillowImageLarge.src, ) }, ) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingEntries.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingEntries.kt index d37ab7a146..fb5457925e 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingEntries.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingEntries.kt @@ -1,6 +1,7 @@ package com.hedvig.android.feature.onboarding.navigation import androidx.navigation3.runtime.EntryProviderScope +import coil3.ImageLoader import com.hedvig.android.feature.onboarding.ui.bundle.OnboardingBundleDestination import com.hedvig.android.feature.onboarding.ui.bundle.OnboardingBundleViewModel import com.hedvig.android.feature.onboarding.ui.coinsured.OnboardingCoInsuredDestination @@ -27,6 +28,7 @@ import dev.zacsweers.metrox.viewmodel.metroViewModel fun EntryProviderScope.onboardingEntries( backstack: Backstack, + imageLoader: ImageLoader, openUrl: (String) -> Unit, openPrivacyPolicy: () -> Unit, navigateToChipId: (contractId: String) -> Unit, @@ -80,7 +82,7 @@ fun EntryProviderScope.onboardingEntries( OnboardingStepId.BundleDiscount -> { val viewModel: OnboardingBundleViewModel = metroViewModel() - OnboardingBundleDestination(viewModel, backstack::navigateUp, openUrl) + OnboardingBundleDestination(viewModel, imageLoader, backstack::navigateUp, openUrl) } } } diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt index e42a6d96c2..d211bdefc8 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt @@ -6,6 +6,9 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -15,15 +18,20 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.ImageLoader +import coil3.compose.AsyncImage import com.hedvig.android.core.common.di.ActivityRetainedScope import com.hedvig.android.core.common.di.HedvigViewModel +import com.hedvig.android.design.system.hedvig.ButtonDefaults +import com.hedvig.android.design.system.hedvig.HedvigButton import com.hedvig.android.design.system.hedvig.HedvigErrorSection import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgressDebounced import com.hedvig.android.design.system.hedvig.HedvigText -import com.hedvig.android.design.system.hedvig.HedvigTextButton import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.placeholder.crossSellPainterFallback import com.hedvig.android.feature.onboarding.data.OnboardingCrossSell import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator @@ -108,6 +116,7 @@ internal sealed interface OnboardingBundleEvent { @Composable internal fun OnboardingBundleDestination( viewModel: OnboardingBundleViewModel, + imageLoader: ImageLoader, navigateUp: () -> Unit, openUrl: (String) -> Unit, ) { @@ -137,24 +146,16 @@ internal fun OnboardingBundleDestination( // TODO: Add "You get a bundle discount when you have two or more insurances with us" / "Du får paketrabatt när du har två eller fler försäkringar hos oss" to Lokalise description = "You get a bundle discount when you have two or more insurances with us", ) - Spacer(Modifier.height(16.dp)) - for (crossSell in state.crossSells) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - ) { - Column(Modifier.weight(1f)) { - HedvigText(crossSell.title) - HedvigText(crossSell.description, color = HedvigTheme.colorScheme.textSecondary) - } - HedvigTextButton( - // TODO: Add "See price" / "Se pris" to Lokalise - text = "See price", - onClick = { openUrl(crossSell.storeUrl) }, - ) + Spacer(Modifier.weight(1f)) + for ((index, crossSell) in state.crossSells.withIndex()) { + if (index > 0) { + Spacer(Modifier.height(8.dp)) } + OnboardingCrossSellRow( + crossSell = crossSell, + imageLoader = imageLoader, + openUrl = openUrl, + ) } Spacer(Modifier.weight(1f)) OnboardingStepButtons( @@ -166,3 +167,50 @@ internal fun OnboardingBundleDestination( } } } + +@Composable +private fun OnboardingCrossSellRow( + crossSell: OnboardingCrossSell, + imageLoader: ImageLoader, + openUrl: (String) -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + if (crossSell.pillowImageUrl != null) { + val placeholder = crossSellPainterFallback() + AsyncImage( + model = crossSell.pillowImageUrl, + contentDescription = null, + placeholder = placeholder, + error = placeholder, + fallback = placeholder, + imageLoader = imageLoader, + modifier = Modifier.size(40.dp), + ) + } else { + Spacer(Modifier.size(40.dp)) + } + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + HedvigText(crossSell.title, style = HedvigTheme.typography.bodySmall) + HedvigText( + crossSell.description, + style = HedvigTheme.typography.bodySmall, + color = HedvigTheme.colorScheme.textSecondary, + ) + } + HedvigButton( + // TODO: Add "See price" / "Se pris" to Lokalise + text = "See price", + onClick = { openUrl(crossSell.storeUrl) }, + enabled = true, + buttonStyle = ButtonDefaults.ButtonStyle.Secondary, + buttonSize = ButtonDefaults.ButtonSize.Small, + modifier = Modifier.clip(CircleShape), + ) + } +} diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/FakeOnboardingRepository.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/FakeOnboardingRepository.kt index a2b3f13a2b..2dde9c50c2 100644 --- a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/FakeOnboardingRepository.kt +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/FakeOnboardingRepository.kt @@ -44,7 +44,15 @@ internal fun testOnboardingData( ), referralInformation: OnboardingReferralInformation? = OnboardingReferralInformation("CODE", 10.0, "SEK"), hasConnectedPayinMethod: Boolean = false, - crossSells: List = listOf(OnboardingCrossSell("cs", "Pet", "For your pet", "https://x")), + crossSells: List = listOf( + OnboardingCrossSell( + id = "cs", + title = "Pet", + description = "For your pet", + storeUrl = "https://x", + pillowImageUrl = "https://x", + ), + ), ): OnboardingData = OnboardingData( email = "member@example.com", phoneNumber = phoneNumber, diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingPathTest.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingPathTest.kt index 0cebd97459..6346f88653 100644 --- a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingPathTest.kt +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingPathTest.kt @@ -26,7 +26,15 @@ class OnboardingPathTest { contracts: List = listOf(contract()), referralInformation: OnboardingReferralInformation? = OnboardingReferralInformation("CODE", 10.0, "SEK"), hasConnectedPayinMethod: Boolean = false, - crossSells: List = listOf(OnboardingCrossSell("cs", "Pet", "For your pet", "https://x")), + crossSells: List = listOf( + OnboardingCrossSell( + id = "cs", + title = "Pet", + description = "For your pet", + storeUrl = "https://x", + pillowImageUrl = "https://x", + ), + ), ) = OnboardingData( email = "member@example.com", phoneNumber = "070 990 12 32", From 910a088eb8ff267ec551fca30508f40d029baaaf Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 22 Jul 2026 15:09:28 +0200 Subject: [PATCH 037/119] Add minimum-path and consent-buffer edge tests, document payin decision --- .../onboarding/data/OnboardingRepository.kt | 2 ++ .../ui/invite/OnboardingInviteDestination.kt | 1 + .../onboarding/data/OnboardingPathTest.kt | 17 +++++++++++ .../ConsentAwareEventTrackingClientTest.kt | 29 +++++++++++++++++++ 4 files changed, 49 insertions(+) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingRepository.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingRepository.kt index f946cc1c0b..00cdc1d233 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingRepository.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingRepository.kt @@ -53,6 +53,8 @@ internal class OnboardingRepositoryImpl( currencyCode = referralInformation.monthlyDiscountPerReferral.currencyCode.rawValue, ) }, + // Deliberately no status filter: a PENDING payin method counts as connected, since bank + // connections take several bank days to activate and re-prompting mid-wait would be wrong. hasConnectedPayinMethod = member.paymentMethods.payinMethods.isNotEmpty(), crossSells = member.crossSellV2.otherCrossSells.map { crossSell -> OnboardingCrossSell( diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt index b5a4ecd9ef..71ee176708 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt @@ -77,6 +77,7 @@ internal class OnboardingInvitePresenter( } else { OnboardingInviteUiState.Content( progress = session.progressFor(OnboardingStepId.InviteFriend), + // toInt is safe: referral incentives are always whole amounts, per product. incentiveDisplay = "${referral.monthlyDiscountPerReferralAmount.toInt()} ${referral.currencyCode}", ) } diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingPathTest.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingPathTest.kt index 6346f88653..8e39ca165e 100644 --- a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingPathTest.kt +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingPathTest.kt @@ -114,4 +114,21 @@ class OnboardingPathTest { ) assertThat(path).contains(OnboardingStepId.BundleDiscount) } + + @Test + fun `a member with nothing applicable gets exactly the mandatory steps`() { + val path = buildOnboardingPath( + data( + contracts = listOf(contract()), + referralInformation = null, + hasConnectedPayinMethod = true, + crossSells = emptyList(), + ), + ) + assertThat(path).containsExactly( + OnboardingStepId.AnalyticsConsent, + OnboardingStepId.PhoneNumber, + OnboardingStepId.Theme, + ) + } } diff --git a/app/tracking/tracking-firebase/src/test/kotlin/com/hedvig/android/tracking/firebase/ConsentAwareEventTrackingClientTest.kt b/app/tracking/tracking-firebase/src/test/kotlin/com/hedvig/android/tracking/firebase/ConsentAwareEventTrackingClientTest.kt index c490c7b528..ce655ec5fb 100644 --- a/app/tracking/tracking-firebase/src/test/kotlin/com/hedvig/android/tracking/firebase/ConsentAwareEventTrackingClientTest.kt +++ b/app/tracking/tracking-firebase/src/test/kotlin/com/hedvig/android/tracking/firebase/ConsentAwareEventTrackingClientTest.kt @@ -164,4 +164,33 @@ class ConsentAwareEventTrackingClientTest { client.setCollectionEnabled(true) assertThat(recording.collectionEnabledCalls.last()).isEqualTo(false) } + + @Test + fun `the buffer caps at 200 events, dropping the oldest`() = runTest { + val recording = RecordingClient() + val settings = FakeSettingsDataStore() + val client = client(recording, settings) + runCurrent() + repeat(201) { index -> + client.trackEvent("event-$index", emptyMap()) + } + settings.consent.value = AnalyticsConsent.GRANTED + runCurrent() + assertThat(recording.events.size).isEqualTo(200) + assertThat(recording.events.first().first).isEqualTo("event-1") + assertThat(recording.events.last().first).isEqualTo("event-200") + } + + @Test + fun `screens are buffered while NOT_DECIDED and flushed on grant`() = runTest { + val recording = RecordingClient() + val settings = FakeSettingsDataStore() + val client = client(recording, settings) + runCurrent() + client.trackScreen("early_screen", null, emptyMap()) + assertThat(recording.screens).isEmpty() + settings.consent.value = AnalyticsConsent.GRANTED + runCurrent() + assertThat(recording.screens).containsExactly("early_screen") + } } From 0ccf8575d14931f13598616afa09a02f614818b6 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 22 Jul 2026 16:40:53 +0200 Subject: [PATCH 038/119] Add onboarding kill switch, dismiss on welcome back, and undecided consent display --- .../feature-onboarding/build.gradle.kts | 2 ++ .../feature/onboarding/gate/OnboardingGate.kt | 14 ++++++++--- .../welcome/OnboardingWelcomeDestination.kt | 6 +++++ .../profile/settings/SettingsDestination.kt | 24 +++++++++++++------ .../featureflags/flags/FeatureUnleashKey.kt | 1 + .../android/featureflags/flags/Feature.kt | 3 +++ 6 files changed, 40 insertions(+), 10 deletions(-) diff --git a/app/feature/feature-onboarding/build.gradle.kts b/app/feature/feature-onboarding/build.gradle.kts index 6d5270e1cc..e4c709980a 100644 --- a/app/feature/feature-onboarding/build.gradle.kts +++ b/app/feature/feature-onboarding/build.gradle.kts @@ -31,6 +31,7 @@ dependencies { implementation(projects.coreCommonPublic) implementation(projects.coreResources) implementation(libs.coil.coil) + implementation(libs.androidx.activity.compose) implementation(libs.coil.compose) implementation(projects.dataContract) implementation(projects.dataSettingsDatastorePublic) @@ -38,6 +39,7 @@ dependencies { implementation(projects.dataCoinsured) implementation(projects.featureConnectPaymentTrustlyNavigation) implementation(projects.featureEditCoinsuredNavigation) + implementation(projects.featureFlags) implementation(projects.foreverUi) implementation(projects.moleculePublic) implementation(projects.navigationCommon) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/gate/OnboardingGate.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/gate/OnboardingGate.kt index 18be024eff..ce5e8153f2 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/gate/OnboardingGate.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/gate/OnboardingGate.kt @@ -4,6 +4,8 @@ import com.hedvig.android.auth.MemberIdService import com.hedvig.android.core.common.di.ActivityRetainedScope import com.hedvig.android.feature.onboarding.data.OnboardingSeenStore import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore +import com.hedvig.android.featureflags.FeatureManager +import com.hedvig.android.featureflags.flags.Feature import com.hedvig.android.logger.LogPriority import com.hedvig.android.logger.logcat import dev.zacsweers.metro.ContributesBinding @@ -12,9 +14,10 @@ import dev.zacsweers.metro.SingleIn import kotlinx.coroutines.flow.first /** - * Decides whether onboarding should be shown for the current member: never seen before, and the - * eager fetch succeeded (caching the session so the flow renders without further loading). On - * fetch failure this returns false WITHOUT marking seen, so the next app start retries. + * Decides whether onboarding should be shown for the current member: not killed by the + * [Feature.DISABLE_ONBOARDING] kill switch, never seen before, and the eager fetch succeeded + * (caching the session so the flow renders without further loading). On fetch failure this + * returns false WITHOUT marking seen, so the next app start retries. */ interface OnboardingGate { suspend fun shouldShowOnboarding(): Boolean @@ -27,8 +30,13 @@ internal class OnboardingGateImpl( private val memberIdService: MemberIdService, private val onboardingSeenStore: OnboardingSeenStore, private val sessionStore: OnboardingSessionStore, + private val featureManager: FeatureManager, ) : OnboardingGate { override suspend fun shouldShowOnboarding(): Boolean { + if (featureManager.isFeatureEnabled(Feature.DISABLE_ONBOARDING).first()) { + logcat(LogPriority.INFO) { "Onboarding is disabled by the kill switch, not showing onboarding" } + return false + } val memberId = memberIdService.getMemberId().first() ?: return false if (onboardingSeenStore.hasSeenOnboarding(memberId)) return false return sessionStore.getOrFetchSession().fold( diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt index f35d1f3289..cc069f6cdf 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt @@ -1,5 +1,6 @@ package com.hedvig.android.feature.onboarding.ui.welcome +import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -101,6 +102,11 @@ internal sealed interface OnboardingWelcomeEvent { @Composable internal fun OnboardingWelcomeDestination(viewModel: OnboardingWelcomeViewModel) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + // System back on the flow root counts as a dismissal, exactly like the close button: it marks + // onboarding as seen so the flow does not silently come back on the next resume. + BackHandler { + viewModel.emit(OnboardingWelcomeEvent.Close) + } OnboardingStepScaffold( progress = (uiState as? OnboardingWelcomeUiState.Content)?.progress, showBackButton = false, diff --git a/app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/settings/SettingsDestination.kt b/app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/settings/SettingsDestination.kt index 9379e238b6..32fbc075d5 100644 --- a/app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/settings/SettingsDestination.kt +++ b/app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/settings/SettingsDestination.kt @@ -184,13 +184,23 @@ private fun SettingsScreen( } onChangeAnalyticsConsent(newConsent) }, - inputText = if (uiState.analyticsConsent == AnalyticsConsent.GRANTED) { - // TODO: Add "On" / "På" to Lokalise - "On" - } else { - // NOT_DECIDED renders as "Off": nothing is forwarded to Firebase in that state. - // TODO: Add "Off" / "Av" to Lokalise - "Off" + inputText = when (uiState.analyticsConsent) { + AnalyticsConsent.GRANTED -> { + // TODO: Add "On" / "På" to Lokalise + "On" + } + + AnalyticsConsent.DENIED -> { + // TODO: Add "Off" / "Av" to Lokalise + "Off" + } + + // No explicit choice made yet: nothing is forwarded to Firebase, but this is shown + // as distinct from an explicit "Off". + AnalyticsConsent.NOT_DECIDED, null -> { + // TODO: Add "Not set" / "Inte valt" to Lokalise + "Not set" + } }, // TODO: Add "Product analytics" / "Produktanalys" to Lokalise labelText = "Product analytics", diff --git a/app/featureflags/feature-flags/src/androidMain/kotlin/com/hedvig/android/featureflags/flags/FeatureUnleashKey.kt b/app/featureflags/feature-flags/src/androidMain/kotlin/com/hedvig/android/featureflags/flags/FeatureUnleashKey.kt index cc610ec0dd..bfc6c75ebc 100644 --- a/app/featureflags/feature-flags/src/androidMain/kotlin/com/hedvig/android/featureflags/flags/FeatureUnleashKey.kt +++ b/app/featureflags/feature-flags/src/androidMain/kotlin/com/hedvig/android/featureflags/flags/FeatureUnleashKey.kt @@ -2,6 +2,7 @@ package com.hedvig.android.featureflags.flags internal val Feature.unleashKey: String get() = when (this) { + Feature.DISABLE_ONBOARDING -> "disable_onboarding" Feature.DISABLE_PUPPY_GUIDE -> "disable_puppy_guide" Feature.ENABLE_CLAIM_INTENT_RESUME -> "enable_claim_intent_resume" Feature.ENABLE_NEW_CONVERSATION_FROM_INBOX -> "enable_new_conversation_from_inbox" diff --git a/app/featureflags/feature-flags/src/commonMain/kotlin/com/hedvig/android/featureflags/flags/Feature.kt b/app/featureflags/feature-flags/src/commonMain/kotlin/com/hedvig/android/featureflags/flags/Feature.kt index bcd389b963..cd41b297f3 100644 --- a/app/featureflags/feature-flags/src/commonMain/kotlin/com/hedvig/android/featureflags/flags/Feature.kt +++ b/app/featureflags/feature-flags/src/commonMain/kotlin/com/hedvig/android/featureflags/flags/Feature.kt @@ -4,6 +4,9 @@ enum class Feature( // Used to easier get a context of what it's for. @Suppress("unused") val explanation: String, ) { + DISABLE_ONBOARDING( + "Kill switch for the post-login onboarding flow. When the toggle is on, onboarding is never shown.", + ), DISABLE_PUPPY_GUIDE( "Kill switch for the puppy guide in the help center. When the toggle is on, the puppy guide is hidden.", ), From 584b9322a4d98df11c7961e52171c19e6a36afdb Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 22 Jul 2026 16:44:48 +0200 Subject: [PATCH 039/119] Adjust copy for non-new members going through the onboarding https://hedviginsurance.slack.com/archives/C03U9C6Q7TP/p1784728584028009 Old members will also see this in various scenarios like first time updating the app. Fresh install. New device bought etc. So this is important to accomodate for them too --- .../onboarding/ui/welcome/OnboardingWelcomeDestination.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt index cc069f6cdf..92f56f0d75 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt @@ -144,10 +144,10 @@ internal fun OnboardingWelcomeDestination(viewModel: OnboardingWelcomeViewModel) text = "Welcome to Hedvig", textAlign = TextAlign.Center, ) - // TODO: Add "Follow the steps to get started with your new insurance" / - // "Följ stegen för att komma igång med din nya försäkring" to Lokalise + // TODO: Add "Follow the steps to get started with your insurance" / + // "Följ stegen för att komma igång med din försäkring" to Lokalise HedvigText( - text = "Follow the steps to get started with your new insurance", + text = "Follow the steps to get started with your insurance", color = HedvigTheme.colorScheme.textSecondary, textAlign = TextAlign.Center, ) From c2c3f3e5d22c3a425719fa9927cdba06db875d58 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Fri, 24 Jul 2026 16:38:11 +0200 Subject: [PATCH 040/119] Mark onboarding seen via dismissal observer instead of a back interceptor --- .../com/hedvig/android/app/ui/HedvigApp.kt | 5 +++- .../feature-onboarding/build.gradle.kts | 1 - .../feature/onboarding/gate/OnboardingGate.kt | 23 +++++++++++++++++++ .../welcome/OnboardingWelcomeDestination.kt | 8 ++----- 4 files changed, 29 insertions(+), 8 deletions(-) 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 6be59712a8..937d2bec28 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 @@ -399,8 +399,10 @@ private fun TryShowOnboardingEffect( LaunchedEffect(lifecycle) { lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { authTokenService.authStatus.first { it is AuthStatus.LoggedIn } - // Already showing (e.g. resumed mid-flow): nothing to do this pass. + // Already showing (e.g. resumed mid-flow, incl. after process death): just re-arm the + // dismissal observer so leaving the flow in any way marks it seen. if (backstackController.entries.any { it is OnboardingKey }) { + onboardingGate.markSeenWhenOnboardingDismissed() return@repeatOnLifecycle } if (!onboardingGate.shouldShowOnboarding()) return@repeatOnLifecycle @@ -410,6 +412,7 @@ private fun TryShowOnboardingEffect( currentDestination is HomeKey && pendingDeepLink == null } backstackController.add(OnboardingKey) + onboardingGate.markSeenWhenOnboardingDismissed() } } } diff --git a/app/feature/feature-onboarding/build.gradle.kts b/app/feature/feature-onboarding/build.gradle.kts index e4c709980a..83e55b2f02 100644 --- a/app/feature/feature-onboarding/build.gradle.kts +++ b/app/feature/feature-onboarding/build.gradle.kts @@ -31,7 +31,6 @@ dependencies { implementation(projects.coreCommonPublic) implementation(projects.coreResources) implementation(libs.coil.coil) - implementation(libs.androidx.activity.compose) implementation(libs.coil.compose) implementation(projects.dataContract) implementation(projects.dataSettingsDatastorePublic) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/gate/OnboardingGate.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/gate/OnboardingGate.kt index ce5e8153f2..2162825765 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/gate/OnboardingGate.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/gate/OnboardingGate.kt @@ -1,13 +1,17 @@ package com.hedvig.android.feature.onboarding.gate +import androidx.compose.runtime.snapshotFlow import com.hedvig.android.auth.MemberIdService import com.hedvig.android.core.common.di.ActivityRetainedScope +import com.hedvig.android.feature.onboarding.data.CompleteOnboardingUseCase import com.hedvig.android.feature.onboarding.data.OnboardingSeenStore import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore +import com.hedvig.android.feature.onboarding.navigation.OnboardingKey import com.hedvig.android.featureflags.FeatureManager import com.hedvig.android.featureflags.flags.Feature import com.hedvig.android.logger.LogPriority import com.hedvig.android.logger.logcat +import com.hedvig.android.navigation.compose.Backstack import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn @@ -21,6 +25,15 @@ import kotlinx.coroutines.flow.first */ interface OnboardingGate { suspend fun shouldShowOnboarding(): Boolean + + /** + * Suspends while the onboarding flow is on the back stack and marks it seen once it leaves, no + * matter how it left: completion, the close button (both already mark seen, this is idempotent), + * or a plain system back on the welcome root, which would otherwise let the flow silently + * reappear on the next resume. Observing removal instead of intercepting back keeps predictive + * back fully native. Returns immediately if the flow is not currently showing. + */ + suspend fun markSeenWhenOnboardingDismissed() } @ContributesBinding(ActivityRetainedScope::class) @@ -31,6 +44,8 @@ internal class OnboardingGateImpl( private val onboardingSeenStore: OnboardingSeenStore, private val sessionStore: OnboardingSessionStore, private val featureManager: FeatureManager, + private val backstack: Backstack, + private val completeOnboardingUseCase: CompleteOnboardingUseCase, ) : OnboardingGate { override suspend fun shouldShowOnboarding(): Boolean { if (featureManager.isFeatureEnabled(Feature.DISABLE_ONBOARDING).first()) { @@ -47,4 +62,12 @@ internal class OnboardingGateImpl( ifRight = { session -> session.path.isNotEmpty() }, ) } + + override suspend fun markSeenWhenOnboardingDismissed() { + if (backstack.entries.none { it is OnboardingKey }) return + snapshotFlow { backstack.entries.none { it is OnboardingKey } }.first { it } + // Idempotent: completion and the close button have already marked seen through this same + // use case; this additionally covers a system back on the welcome root. + completeOnboardingUseCase.invoke() + } } diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt index 92f56f0d75..8910b9adad 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt @@ -1,6 +1,5 @@ package com.hedvig.android.feature.onboarding.ui.welcome -import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -102,11 +101,8 @@ internal sealed interface OnboardingWelcomeEvent { @Composable internal fun OnboardingWelcomeDestination(viewModel: OnboardingWelcomeViewModel) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - // System back on the flow root counts as a dismissal, exactly like the close button: it marks - // onboarding as seen so the flow does not silently come back on the next resume. - BackHandler { - viewModel.emit(OnboardingWelcomeEvent.Close) - } + // System back here pops the flow root natively (predictive back preview included); marking the + // flow as seen on that dismissal is handled by OnboardingGate.markSeenWhenOnboardingDismissed. OnboardingStepScaffold( progress = (uiState as? OnboardingWelcomeUiState.Content)?.progress, showBackButton = false, From c4849e68674614fd19c6240281b4abe05f46924a Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Fri, 24 Jul 2026 16:39:00 +0200 Subject: [PATCH 041/119] Add onboarding manual QA checklist --- .../2026-07-24-onboarding-qa-checklist.md | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 docs/superpowers/2026-07-24-onboarding-qa-checklist.md diff --git a/docs/superpowers/2026-07-24-onboarding-qa-checklist.md b/docs/superpowers/2026-07-24-onboarding-qa-checklist.md new file mode 100644 index 0000000000..42bdba213d --- /dev/null +++ b/docs/superpowers/2026-07-24-onboarding-qa-checklist.md @@ -0,0 +1,89 @@ +# Onboarding manual QA checklist + +Branch: `feat/onboarding`. Check off as you go; add notes inline under any failing item. + +## Setup + +- [ ] Build and install: `./gradlew :app:installDebug` (app id `com.hedvig.dev.app`, staging backend) +- [ ] Unleash: `disable_onboarding` toggle created, currently OFF +- [ ] Member A ("maximal"): missing co-insured, pet missing ID, no payin method, referral code, cross-sell eligible (expect 9 progress segments) +- [ ] Member B ("complete"): payment connected, nothing missing (expect 4 segments: welcome, consent, phone, theme) +- [ ] Firebase DebugView armed: `adb shell setprop debug.firebase.analytics.app com.hedvig.dev.app` + +Reset between runs: Profile > About app > **Reset onboarding** (non-production row), then background + foreground the app. `adb shell pm clear com.hedvig.dev.app` only when you also want to wipe the session cache and consent. + +## A. Gate and show-once + +- [ ] A1. Fresh login, land on Home: onboarding appears (welcome, no back arrow, progress at segment 1) +- [ ] A2. Segment count matches the member's applicable steps (compare A vs B members) +- [ ] A3. X on any step: back on Home; kill + relaunch: never returns +- [ ] A4. Complete via "Continue to app": same, never returns +- [ ] A5. System back on welcome: predictive-back preview works natively, flow dismisses, AND is marked seen (background + foreground: does not come back) +- [ ] A6. Kill switch: `disable_onboarding` ON + reset onboarding + background/foreground: nothing appears; OFF again + background/foreground: appears +- [ ] A7. Member switch: logout, login as unseen member B: appears with B's data everywhere (phone, incentive, contracts; no member A leakage) +- [ ] A8. Offline first reach of Home: no flow, no crash; back online, kill + relaunch: appears (failure never marks seen) +- [ ] A9. Deep link (`adb shell am start -a android.intent.action.VIEW -d ""`) with unseen member: deep-linked screen uninterrupted; flow appears once on an idle Home +- [ ] A10. Demo mode: no onboarding + +## B. Navigation mechanics + +- [ ] B1. Back arrow pops exactly one step; system back matches on every step, predictive preview included +- [ ] B2. Process death mid-flow (background, `adb shell am kill com.hedvig.dev.app`, reopen from recents): step restores, loading, refetch, progress bar returns +- [ ] B3. After a process-death restore, system back on welcome STILL marks seen (the dismissal observer re-arms on resume) +- [ ] B4. Rotate every screen + +## C. Per step + +### Consent +- [ ] Underlined "Privacy policy" + northeast arrow opens the language-correct URL +- [ ] Allow and Deny are two identical grey pills, Allow on top +- [ ] Allow advances; Settings > Product analytics shows "On" +- [ ] Deny advances; Settings shows "Off" +- [ ] Decide nothing (dismiss flow first): Settings shows "Not set" +- [ ] DebugView: pre-decision events buffered, arrive with `buffered_at_epoch_ms` on Allow +- [ ] DebugView: Deny stops everything including `session_start` (SDK collection off); Datadog still reports + +### Phone +- [ ] Field bottom-anchored above Save; no cursor lag while typing +- [ ] Pre-filled with current number +- [ ] Save advances; profile contact info shows the new number +- [ ] Save offline: inline error, typed digits preserved, stays on step +- [ ] Blank number disables Save +- [ ] "Do this later" (grey pill) advances without any mutation +- [ ] Back to the step after a successful save: Save enabled again + +### Theme +- [ ] Design-system radio option cards (title + subtitle) +- [ ] Selection applies instantly app-wide, survives relaunch, agrees with the Settings screen row + +### Co-insured / Pet ID +- [ ] Centered grey cards with contract pillow images, dark pill "Add" +- [ ] Add opens the existing flow; completing it returns to the step with a checkmark on that card (row does not vanish) +- [ ] Aborting the external flow returns with the row unchanged +- [ ] Fine-print hint above the buttons + +### Invite a friend +- [ ] Card with Hampus / Li / Elin, green dots, real "-10 SEK"-style amounts; no referral code shown +- [ ] "Invite a friend" (grey, ABOVE Continue) opens the Forever screen standalone, no bottom nav +- [ ] System back from Forever returns to the invite step with the flow intact + +### Connect payment +- [ ] Fine-print caption; single "Connect payment" pill (no do-later; only X) +- [ ] Trustly completion (staging test bank): connected variant (text + Continue, no checkmark icon) +- [ ] Aborted Trustly that left a PENDING method showing "connected" is CORRECT behavior (deliberate) + +### Bundle discount +- [ ] Cross-sell pillow images load (fallback painter offline) +- [ ] Grey "See price" pills open the storeUrl in the browser +- [ ] "Continue to app" ends the flow + +## D. Regression edges + +- [ ] Settings consent row round-trips On / Off / Not set; DebugView agrees +- [ ] Standalone flows unaffected: edit co-insured, chip-id, Trustly, Forever tab, profile settings +- [ ] TalkBack: back/close announce "Go back" / "Close" with 40dp+ targets +- [ ] Known: repo-wide `ktlintCheck` fails on `feature-payments` (pre-existing on develop, not this branch) + +## Notes / found issues + +- From 0f636495adb642aad5ac2f45308c22f3684ee800 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Fri, 24 Jul 2026 17:24:25 +0200 Subject: [PATCH 042/119] Remove orphaned back-handling comment from onboarding welcome --- .../onboarding/ui/welcome/OnboardingWelcomeDestination.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt index 8910b9adad..e55daccb20 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt @@ -101,8 +101,6 @@ internal sealed interface OnboardingWelcomeEvent { @Composable internal fun OnboardingWelcomeDestination(viewModel: OnboardingWelcomeViewModel) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - // System back here pops the flow root natively (predictive back preview included); marking the - // flow as seen on that dismissal is handled by OnboardingGate.markSeenWhenOnboardingDismissed. OnboardingStepScaffold( progress = (uiState as? OnboardingWelcomeUiState.Content)?.progress, showBackButton = false, From 0ecb8243c0343e0c341c1f32faf14b21ae97789c Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Mon, 27 Jul 2026 16:44:55 +0200 Subject: [PATCH 043/119] Use Lokalise string resources in onboarding screens --- .../onboarding/ui/OnboardingContractCard.kt | 9 ++++++ .../onboarding/ui/OnboardingStepScaffold.kt | 10 ++++--- .../ui/bundle/OnboardingBundleDestination.kt | 14 ++++++---- .../OnboardingCoInsuredDestination.kt | 22 ++++++++------- .../consent/OnboardingConsentDestination.kt | 24 ++++++++-------- .../ui/invite/OnboardingInviteDestination.kt | 17 +++++------ .../payment/OnboardingPaymentDestination.kt | 26 ++++++++--------- .../ui/petid/OnboardingPetIdDestination.kt | 22 ++++++++------- .../ui/phone/OnboardingPhoneDestination.kt | 22 +++++++-------- .../ui/theme/OnboardingThemeDestination.kt | 28 +++++++++---------- .../welcome/OnboardingWelcomeDestination.kt | 15 +++++----- 11 files changed, 113 insertions(+), 96 deletions(-) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingContractCard.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingContractCard.kt index a5c017c11a..c2514bcbc9 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingContractCard.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingContractCard.kt @@ -24,7 +24,10 @@ import com.hedvig.android.design.system.hedvig.HedvigTheme import com.hedvig.android.design.system.hedvig.Icon import com.hedvig.android.design.system.hedvig.icon.Checkmark import com.hedvig.android.design.system.hedvig.icon.HedvigIcons +import hedvig.resources.ONBOARDING_ADDED_LABEL +import hedvig.resources.Res import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource @Composable internal fun OnboardingContractCard( @@ -65,6 +68,12 @@ internal fun OnboardingContractCard( } Spacer(Modifier.width(12.dp)) if (isComplete) { + HedvigText( + text = stringResource(Res.string.ONBOARDING_ADDED_LABEL), + style = HedvigTheme.typography.bodySmall, + color = HedvigTheme.colorScheme.textSecondary, + ) + Spacer(Modifier.width(8.dp)) Icon(imageVector = HedvigIcons.Checkmark, contentDescription = null) } else { HedvigButton( diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt index dbcef35721..f90ebdbc45 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt @@ -38,6 +38,10 @@ import com.hedvig.android.design.system.hedvig.icon.Close import com.hedvig.android.design.system.hedvig.icon.HedvigIcons import com.hedvig.android.feature.onboarding.data.OnboardingSession import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId +import hedvig.resources.Res +import hedvig.resources.general_back_button +import hedvig.resources.general_close_button +import org.jetbrains.compose.resources.stringResource internal data class OnboardingProgress( val totalSteps: Int, @@ -87,8 +91,7 @@ internal fun OnboardingStepScaffold( ) { Icon( imageVector = HedvigIcons.ArrowLeft, - // TODO: Add "Go back" / "Gå tillbaka" to Lokalise (or reuse an existing a11y string) - contentDescription = "Go back", + contentDescription = stringResource(Res.string.general_back_button), modifier = Modifier.size(24.dp), ) } @@ -110,8 +113,7 @@ internal fun OnboardingStepScaffold( ) { Icon( imageVector = HedvigIcons.Close, - // TODO: Add "Close" / "Stäng" to Lokalise (or reuse an existing a11y string) - contentDescription = "Close", + contentDescription = stringResource(Res.string.general_close_button), modifier = Modifier.size(24.dp), ) } diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt index d211bdefc8..9e7355eaf4 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt @@ -45,7 +45,12 @@ import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel import dev.zacsweers.metro.Inject +import hedvig.resources.ONBOARDING_CONTINUE_TO_APP_BUTTON +import hedvig.resources.ONBOARDING_CROSS_SELL_SUBTITLE +import hedvig.resources.ONBOARDING_CROSS_SELL_TITLE +import hedvig.resources.Res import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource @Inject @HedvigViewModel(ActivityRetainedScope::class) @@ -141,10 +146,8 @@ internal fun OnboardingBundleDestination( is OnboardingBundleUiState.Content -> { Spacer(Modifier.height(16.dp)) OnboardingStepHeader( - // TODO: Add "Get bundle discount" / "Få paketrabatt" to Lokalise - title = "Get bundle discount", - // TODO: Add "You get a bundle discount when you have two or more insurances with us" / "Du får paketrabatt när du har två eller fler försäkringar hos oss" to Lokalise - description = "You get a bundle discount when you have two or more insurances with us", + title = stringResource(Res.string.ONBOARDING_CROSS_SELL_TITLE), + description = stringResource(Res.string.ONBOARDING_CROSS_SELL_SUBTITLE), ) Spacer(Modifier.weight(1f)) for ((index, crossSell) in state.crossSells.withIndex()) { @@ -159,8 +162,7 @@ internal fun OnboardingBundleDestination( } Spacer(Modifier.weight(1f)) OnboardingStepButtons( - // TODO: Add "Continue to app" / "Fortsätt till appen" to Lokalise - primaryText = "Continue to app", + primaryText = stringResource(Res.string.ONBOARDING_CONTINUE_TO_APP_BUTTON), onPrimaryClick = { viewModel.emit(OnboardingBundleEvent.ContinueToApp) }, ) } diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt index eaa44a207c..514f628616 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt @@ -39,7 +39,14 @@ import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel import dev.zacsweers.metro.Inject +import hedvig.resources.ONBOARDING_ADD_COINSURED_TITLE +import hedvig.resources.ONBOARDING_ADD_INFO_LATER_LABEL +import hedvig.resources.ONBOARDING_DO_THIS_LATER_BUTTON +import hedvig.resources.ONBOARDING_MISSING_INFO_SUBTITLE +import hedvig.resources.Res +import hedvig.resources.general_continue_button import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource @Inject @HedvigViewModel(ActivityRetainedScope::class) @@ -179,10 +186,8 @@ internal fun OnboardingCoInsuredDestination(viewModel: OnboardingCoInsuredViewMo is OnboardingCoInsuredUiState.Content -> { Spacer(Modifier.height(16.dp)) OnboardingStepHeader( - // TODO: Add "Add co-insured" / "Lägg till medförsäkrade" to Lokalise - title = "Add co-insured", - // TODO: Add "So we know who's covered by your insurance" / "Så att vi vet vem som är försäkrad" to Lokalise - description = "So we know who's covered by your insurance", + title = stringResource(Res.string.ONBOARDING_ADD_COINSURED_TITLE), + description = stringResource(Res.string.ONBOARDING_MISSING_INFO_SUBTITLE), ) Spacer(Modifier.weight(1f)) Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { @@ -198,8 +203,7 @@ internal fun OnboardingCoInsuredDestination(viewModel: OnboardingCoInsuredViewMo } Spacer(Modifier.weight(1f)) HedvigText( - // TODO: Add "You can add this information later" / "Du kan lägga till den här informationen senare" to Lokalise - text = "You can add this information later", + text = stringResource(Res.string.ONBOARDING_ADD_INFO_LATER_LABEL), style = HedvigTheme.typography.finePrint, color = HedvigTheme.colorScheme.textSecondary, textAlign = TextAlign.Center, @@ -209,11 +213,9 @@ internal fun OnboardingCoInsuredDestination(viewModel: OnboardingCoInsuredViewMo ) Spacer(Modifier.height(16.dp)) OnboardingStepButtons( - // TODO: Add "Continue" / "Fortsätt" to Lokalise - primaryText = "Continue", + primaryText = stringResource(Res.string.general_continue_button), onPrimaryClick = { viewModel.emit(OnboardingCoInsuredEvent.Continue) }, - // TODO: Add "Do this later" / "Gör det senare" to Lokalise - secondaryText = "Do this later", + secondaryText = stringResource(Res.string.ONBOARDING_DO_THIS_LATER_BUTTON), onSecondaryClick = { viewModel.emit(OnboardingCoInsuredEvent.Continue) }, ) } diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingConsentDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingConsentDestination.kt index 5ce72932a7..4113e485bc 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingConsentDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingConsentDestination.kt @@ -50,7 +50,14 @@ import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel import dev.zacsweers.metro.Inject +import hedvig.resources.LEGAL_PRIVACY_POLICY +import hedvig.resources.ONBOARDING_ANALYTICS_ALLOW_BUTTON +import hedvig.resources.ONBOARDING_ANALYTICS_DENY_BUTTON +import hedvig.resources.ONBOARDING_ANALYTICS_SUBTITLE +import hedvig.resources.ONBOARDING_ANALYTICS_TITLE +import hedvig.resources.Res import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource @Inject @HedvigViewModel(ActivityRetainedScope::class) @@ -153,15 +160,10 @@ internal fun OnboardingConsentDestination( is OnboardingConsentUiState.Content -> { Spacer(Modifier.height(16.dp)) OnboardingStepHeader( - // TODO: Add "Help us make the app better" / "Hjälp oss göra appen bättre" to Lokalise - title = "Help us make the app better", - // TODO: Add the body copy below (and its Swedish translation) to Lokalise - description = "We use technical tools to see how you use the app, so we can make it better.\n\n" + - "Product analytics is completely optional and can be turned off any time in settings. " + - "This data is never used for marketing.", + title = stringResource(Res.string.ONBOARDING_ANALYTICS_TITLE), + description = stringResource(Res.string.ONBOARDING_ANALYTICS_SUBTITLE), ) Spacer(Modifier.weight(1f)) - // TODO: Add "Privacy policy" / "Integritetspolicy" to Lokalise Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier @@ -171,7 +173,7 @@ internal fun OnboardingConsentDestination( .padding(horizontal = 8.dp, vertical = 4.dp), ) { HedvigText( - text = "Privacy policy", + text = stringResource(Res.string.LEGAL_PRIVACY_POLICY), style = HedvigTheme.typography.bodySmall, textDecoration = TextDecoration.Underline, ) @@ -182,9 +184,8 @@ internal fun OnboardingConsentDestination( ) } Spacer(Modifier.height(16.dp)) - // TODO: Add "Allow" / "Tillåt" to Lokalise HedvigButton( - text = "Allow", + text = stringResource(Res.string.ONBOARDING_ANALYTICS_ALLOW_BUTTON), onClick = { viewModel.emit(OnboardingConsentEvent.Allow) }, enabled = true, buttonStyle = ButtonDefaults.ButtonStyle.Secondary, @@ -194,9 +195,8 @@ internal fun OnboardingConsentDestination( .clip(CircleShape), ) Spacer(Modifier.height(8.dp)) - // TODO: Add "Deny" / "Neka" to Lokalise HedvigButton( - text = "Deny", + text = stringResource(Res.string.ONBOARDING_ANALYTICS_DENY_BUTTON), onClick = { viewModel.emit(OnboardingConsentEvent.Deny) }, enabled = true, buttonStyle = ButtonDefaults.ButtonStyle.Secondary, diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt index 71ee176708..83a401cfb8 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt @@ -42,7 +42,12 @@ import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel import dev.zacsweers.metro.Inject +import hedvig.resources.ONBOARDING_INVITE_FRIEND_SUBTITLE +import hedvig.resources.ONBOARDING_INVITE_FRIEND_TITLE +import hedvig.resources.Res +import hedvig.resources.general_continue_button import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource @Inject @HedvigViewModel(ActivityRetainedScope::class) @@ -142,10 +147,8 @@ internal fun OnboardingInviteDestination(viewModel: OnboardingInviteViewModel, n is OnboardingInviteUiState.Content -> { Spacer(Modifier.height(16.dp)) OnboardingStepHeader( - // TODO: Add "Invite a friend" / "Bjud in en vän" to Lokalise - title = "Invite a friend", - // TODO: Add the body copy below (and its Swedish translation) to Lokalise - description = "With Hedvig Forever, you get ${state.incentiveDisplay} off for every friend you invite", + title = stringResource(Res.string.ONBOARDING_INVITE_FRIEND_TITLE), + description = stringResource(Res.string.ONBOARDING_INVITE_FRIEND_SUBTITLE, state.incentiveDisplay), ) Spacer(Modifier.weight(1f)) ExampleReferralsCard( @@ -154,11 +157,9 @@ internal fun OnboardingInviteDestination(viewModel: OnboardingInviteViewModel, n ) Spacer(Modifier.weight(1f)) OnboardingStepButtons( - // TODO: Add "Continue" / "Fortsätt" to Lokalise - primaryText = "Continue", + primaryText = stringResource(Res.string.general_continue_button), onPrimaryClick = { viewModel.emit(OnboardingInviteEvent.Continue) }, - // TODO: Add "Invite a friend" / "Bjud in en vän" to Lokalise - secondaryText = "Invite a friend", + secondaryText = stringResource(Res.string.ONBOARDING_INVITE_FRIEND_TITLE), secondaryAbovePrimary = true, onSecondaryClick = { viewModel.emit(OnboardingInviteEvent.InviteFriend) }, ) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt index 28a4df85da..aff6b6d3a5 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt @@ -34,7 +34,13 @@ import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel import dev.zacsweers.metro.Inject +import hedvig.resources.ONBOARDING_CONNECT_PAYMENT_FOOTNOTE +import hedvig.resources.ONBOARDING_CONNECT_PAYMENT_SUBTITLE +import hedvig.resources.ONBOARDING_CONNECT_PAYMENT_TITLE +import hedvig.resources.Res +import hedvig.resources.general_continue_button import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource @Inject @HedvigViewModel(ActivityRetainedScope::class) @@ -154,17 +160,12 @@ internal fun OnboardingPaymentDestination(viewModel: OnboardingPaymentViewModel, Spacer(Modifier.height(16.dp)) if (!content.isConnected) { OnboardingStepHeader( - // TODO: Add "Connect payment" / "Koppla betalning" to Lokalise - title = "Connect payment", - // TODO: Add "Add a payment method to activate your insurance" / - // "Lägg till en betalmetod för att aktivera din försäkring" to Lokalise - description = "Add a payment method to activate your insurance", + title = stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_TITLE), + description = stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_SUBTITLE), ) Spacer(Modifier.weight(1f)) - // TODO: Add "Adding a payment method is required to keep your insurance active" / - // "Du behöver lägga till en betalmetod för att hålla din försäkring aktiv" to Lokalise HedvigText( - text = "Adding a payment method is required to keep your insurance active", + text = stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_FOOTNOTE), style = HedvigTheme.typography.finePrint, color = HedvigTheme.colorScheme.textSecondary, modifier = Modifier @@ -173,14 +174,12 @@ internal fun OnboardingPaymentDestination(viewModel: OnboardingPaymentViewModel, ) Spacer(Modifier.height(16.dp)) OnboardingStepButtons( - // TODO: Add "Connect payment" / "Koppla betalning" to Lokalise - primaryText = "Connect payment", + primaryText = stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_TITLE), onPrimaryClick = { viewModel.emit(OnboardingPaymentEvent.ConnectPayment) }, ) } else { OnboardingStepHeader( - // TODO: Add "Connect payment" / "Koppla betalning" to Lokalise - title = "Connect payment", + title = stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_TITLE), // TODO: Add "Your payment method is connected" / "Din betalmetod är kopplad" to Lokalise description = "Your payment method is connected", ) @@ -195,8 +194,7 @@ internal fun OnboardingPaymentDestination(viewModel: OnboardingPaymentViewModel, ) Spacer(Modifier.height(16.dp)) OnboardingStepButtons( - // TODO: Add "Continue" / "Fortsätt" to Lokalise - primaryText = "Continue", + primaryText = stringResource(Res.string.general_continue_button), onPrimaryClick = { viewModel.emit(OnboardingPaymentEvent.Continue) }, ) } diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdDestination.kt index 6b13cb7ce7..05411980f2 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdDestination.kt @@ -39,7 +39,14 @@ import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel import dev.zacsweers.metro.Inject +import hedvig.resources.ONBOARDING_ADD_INFO_LATER_LABEL +import hedvig.resources.ONBOARDING_ADD_PET_CHIP_IDS_TITLE +import hedvig.resources.ONBOARDING_DO_THIS_LATER_BUTTON +import hedvig.resources.ONBOARDING_MISSING_INFO_PET_SUBTITLE +import hedvig.resources.Res +import hedvig.resources.general_continue_button import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource @Inject @HedvigViewModel(ActivityRetainedScope::class) @@ -179,10 +186,8 @@ internal fun OnboardingPetIdDestination( is OnboardingPetIdUiState.Content -> { Spacer(Modifier.height(16.dp)) OnboardingStepHeader( - // TODO: Add "Add your pets ID numbers" / "Lägg till dina djurs ID-nummer" to Lokalise - title = "Add your pets ID numbers", - // TODO: Add "This makes it easier to help you if something happens" / "Det gör det lättare att hjälpa dig om något händer" to Lokalise - description = "This makes it easier to help you if something happens", + title = stringResource(Res.string.ONBOARDING_ADD_PET_CHIP_IDS_TITLE), + description = stringResource(Res.string.ONBOARDING_MISSING_INFO_PET_SUBTITLE), ) Spacer(Modifier.weight(1f)) Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { @@ -198,8 +203,7 @@ internal fun OnboardingPetIdDestination( } Spacer(Modifier.weight(1f)) HedvigText( - // TODO: Add "You can add this information later" / "Du kan lägga till den här informationen senare" to Lokalise - text = "You can add this information later", + text = stringResource(Res.string.ONBOARDING_ADD_INFO_LATER_LABEL), style = HedvigTheme.typography.finePrint, color = HedvigTheme.colorScheme.textSecondary, textAlign = TextAlign.Center, @@ -209,11 +213,9 @@ internal fun OnboardingPetIdDestination( ) Spacer(Modifier.height(16.dp)) OnboardingStepButtons( - // TODO: Add "Continue" / "Fortsätt" to Lokalise - primaryText = "Continue", + primaryText = stringResource(Res.string.general_continue_button), onPrimaryClick = { viewModel.emit(OnboardingPetIdEvent.Continue) }, - // TODO: Add "Do this later" / "Gör det senare" to Lokalise - secondaryText = "Do this later", + secondaryText = stringResource(Res.string.ONBOARDING_DO_THIS_LATER_BUTTON), onSecondaryClick = { viewModel.emit(OnboardingPetIdEvent.Continue) }, ) } diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt index a6a1855876..4254b85d60 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt @@ -37,8 +37,14 @@ import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel import dev.zacsweers.metro.Inject +import hedvig.resources.ONBOARDING_DO_THIS_LATER_BUTTON +import hedvig.resources.ONBOARDING_PHONE_SUBTITLE +import hedvig.resources.ONBOARDING_PHONE_TITLE +import hedvig.resources.Res +import hedvig.resources.general_save_button import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource @Inject @HedvigViewModel(ActivityRetainedScope::class) @@ -184,17 +190,13 @@ internal fun OnboardingPhoneDestination(viewModel: OnboardingPhoneViewModel, nav } Spacer(Modifier.height(16.dp)) OnboardingStepHeader( - // TODO: Add "Phone number" / "Telefonnummer" to Lokalise - title = "Phone number", - // TODO: Add "Add your phone number so we can reach you if something happens" / - // "Lägg till ditt telefonnummer så att vi kan nå dig om något händer" to Lokalise - description = "Add your phone number so we can reach you if something happens", + title = stringResource(Res.string.ONBOARDING_PHONE_TITLE), + description = stringResource(Res.string.ONBOARDING_PHONE_SUBTITLE), ) Spacer(Modifier.weight(1f)) HedvigTextField( state = phoneNumberState, - // TODO: Add "Phone number" / "Telefonnummer" to Lokalise - labelText = "Phone number", + labelText = stringResource(Res.string.ONBOARDING_PHONE_TITLE), textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, errorState = if (content.showSubmissionError) { // TODO: Add "Could not save, please try again" / "Kunde inte spara, försök igen" to Lokalise @@ -209,12 +211,10 @@ internal fun OnboardingPhoneDestination(viewModel: OnboardingPhoneViewModel, nav ) Spacer(Modifier.height(16.dp)) OnboardingStepButtons( - // TODO: Add "Save" / "Spara" to Lokalise - primaryText = "Save", + primaryText = stringResource(Res.string.general_save_button), onPrimaryClick = { viewModel.emit(OnboardingPhoneEvent.Save(phoneNumberState.text.toString())) }, primaryEnabled = !content.isSubmitting && phoneNumberState.text.isNotBlank(), - // TODO: Add "Do this later" / "Gör det senare" to Lokalise - secondaryText = "Do this later", + secondaryText = stringResource(Res.string.ONBOARDING_DO_THIS_LATER_BUTTON), onSecondaryClick = { viewModel.emit(OnboardingPhoneEvent.DoThisLater) }, ) } diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt index f1c0b2220b..e8216df0b0 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt @@ -39,10 +39,17 @@ import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel import com.hedvig.android.theme.Theme import dev.zacsweers.metro.Inject +import hedvig.resources.ONBOARDING_CHANGE_SETTINGS_LATER_LABEL +import hedvig.resources.ONBOARDING_THEME_DARK_SUBTITLE +import hedvig.resources.ONBOARDING_THEME_LIGHT_SUBTITLE +import hedvig.resources.ONBOARDING_THEME_SUBTITLE +import hedvig.resources.ONBOARDING_THEME_SYSTEM_SUBTITLE import hedvig.resources.Res import hedvig.resources.SETTINGS_THEME_DARK +import hedvig.resources.SETTINGS_THEME_DIALOG_TITLE import hedvig.resources.SETTINGS_THEME_LIGHT import hedvig.resources.SETTINGS_THEME_SYSTEM_DEFAULT +import hedvig.resources.general_continue_button import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource @@ -150,10 +157,8 @@ internal fun OnboardingThemeDestination(viewModel: OnboardingThemeViewModel, nav val content = uiState as OnboardingThemeUiState.Content Spacer(Modifier.height(16.dp)) OnboardingStepHeader( - // TODO: Add "Choose theme" / "Välj tema" to Lokalise - title = "Choose theme", - // TODO: Add "Customize the look of the app" / "Anpassa utseendet på appen" to Lokalise - description = "Customize the look of the app", + title = stringResource(Res.string.SETTINGS_THEME_DIALOG_TITLE), + description = stringResource(Res.string.ONBOARDING_THEME_SUBTITLE), ) Spacer(Modifier.weight(1f)) RadioGroup( @@ -161,20 +166,17 @@ internal fun OnboardingThemeDestination(viewModel: OnboardingThemeViewModel, nav RadioOption( id = RadioOptionId(Theme.SYSTEM_DEFAULT.name), text = stringResource(Res.string.SETTINGS_THEME_SYSTEM_DEFAULT), - // TODO: Add "Uses your phone's setting" / "Använder din telefons inställning" to Lokalise - label = "Uses your phone's setting", + label = stringResource(Res.string.ONBOARDING_THEME_SYSTEM_SUBTITLE), ), RadioOption( id = RadioOptionId(Theme.LIGHT.name), text = stringResource(Res.string.SETTINGS_THEME_LIGHT), - // TODO: Add "Set light mode" / "Ange ljust läge" to Lokalise - label = "Set light mode", + label = stringResource(Res.string.ONBOARDING_THEME_LIGHT_SUBTITLE), ), RadioOption( id = RadioOptionId(Theme.DARK.name), text = stringResource(Res.string.SETTINGS_THEME_DARK), - // TODO: Add "Set dark mode" / "Ange mörkt läge" to Lokalise - label = "Set dark mode", + label = stringResource(Res.string.ONBOARDING_THEME_DARK_SUBTITLE), ), ), selectedOption = RadioOptionId(content.selectedTheme.name), @@ -185,8 +187,7 @@ internal fun OnboardingThemeDestination(viewModel: OnboardingThemeViewModel, nav ) Spacer(Modifier.weight(1f)) HedvigText( - // TODO: Add "You can change these settings later" / "Du kan ändra dessa inställningar senare" to Lokalise - text = "You can change these settings later", + text = stringResource(Res.string.ONBOARDING_CHANGE_SETTINGS_LATER_LABEL), style = HedvigTheme.typography.finePrint, color = HedvigTheme.colorScheme.textSecondary, textAlign = TextAlign.Center, @@ -196,8 +197,7 @@ internal fun OnboardingThemeDestination(viewModel: OnboardingThemeViewModel, nav ) Spacer(Modifier.height(16.dp)) OnboardingStepButtons( - // TODO: Add "Continue" / "Fortsätt" to Lokalise - primaryText = "Continue", + primaryText = stringResource(Res.string.general_continue_button), onPrimaryClick = { viewModel.emit(OnboardingThemeEvent.Continue) }, ) } diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt index e55daccb20..34ae3234f2 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt @@ -36,7 +36,12 @@ import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel import dev.zacsweers.metro.Inject +import hedvig.resources.ONBOARDING_WELCOME_BUTTON +import hedvig.resources.ONBOARDING_WELCOME_SUBTITLE +import hedvig.resources.ONBOARDING_WELCOME_TITLE +import hedvig.resources.Res import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource @Inject @HedvigViewModel(ActivityRetainedScope::class) @@ -133,23 +138,19 @@ internal fun OnboardingWelcomeDestination(viewModel: OnboardingWelcomeViewModel) .background(HedvigTheme.colorScheme.fillPrimary, HedvigTheme.shapes.cornerLarge), ) Spacer(Modifier.height(24.dp)) - // TODO: Add "Welcome to Hedvig" / "Välkommen till Hedvig" to Lokalise HedvigText( - text = "Welcome to Hedvig", + text = stringResource(Res.string.ONBOARDING_WELCOME_TITLE), textAlign = TextAlign.Center, ) - // TODO: Add "Follow the steps to get started with your insurance" / - // "Följ stegen för att komma igång med din försäkring" to Lokalise HedvigText( - text = "Follow the steps to get started with your insurance", + text = stringResource(Res.string.ONBOARDING_WELCOME_SUBTITLE), color = HedvigTheme.colorScheme.textSecondary, textAlign = TextAlign.Center, ) } Spacer(Modifier.weight(1f)) - // TODO: Add "Get started" / "Kom igång" to Lokalise OnboardingStepButtons( - primaryText = "Get started", + primaryText = stringResource(Res.string.ONBOARDING_WELCOME_BUTTON), onPrimaryClick = { viewModel.emit(OnboardingWelcomeEvent.GetStarted) }, ) } From 03b3bd71dafec66493c1f41b0dd74add32a77985 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Mon, 27 Jul 2026 17:16:16 +0200 Subject: [PATCH 044/119] Tighten onboarding hint spacing, add theme icons, inset invite card --- .../ui/coinsured/OnboardingCoInsuredDestination.kt | 1 - .../onboarding/ui/invite/OnboardingInviteDestination.kt | 7 +++++-- .../ui/payment/OnboardingPaymentDestination.kt | 2 -- .../onboarding/ui/petid/OnboardingPetIdDestination.kt | 1 - .../onboarding/ui/theme/OnboardingThemeDestination.kt | 9 ++++++++- 5 files changed, 13 insertions(+), 7 deletions(-) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt index 514f628616..3040ba1425 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt @@ -211,7 +211,6 @@ internal fun OnboardingCoInsuredDestination(viewModel: OnboardingCoInsuredViewMo .fillMaxWidth() .padding(horizontal = 16.dp), ) - Spacer(Modifier.height(16.dp)) OnboardingStepButtons( primaryText = stringResource(Res.string.general_continue_button), onPrimaryClick = { viewModel.emit(OnboardingCoInsuredEvent.Continue) }, diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt index 83a401cfb8..b3e981dc5a 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -153,7 +154,9 @@ internal fun OnboardingInviteDestination(viewModel: OnboardingInviteViewModel, n Spacer(Modifier.weight(1f)) ExampleReferralsCard( incentiveDisplay = state.incentiveDisplay, - modifier = Modifier.align(Alignment.CenterHorizontally), + modifier = Modifier + .align(Alignment.CenterHorizontally) + .fillMaxWidth(0.72f), ) Spacer(Modifier.weight(1f)) OnboardingStepButtons( @@ -194,7 +197,7 @@ private fun ExampleReferralsCard(incentiveDisplay: String, modifier: Modifier = ) Spacer(Modifier.width(8.dp)) HedvigText(text = name, style = HedvigTheme.typography.bodySmall) - Spacer(Modifier.width(32.dp).weight(1f)) + Spacer(Modifier.weight(1f)) HedvigText(text = "-$incentiveDisplay", style = HedvigTheme.typography.bodySmall) } } diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt index aff6b6d3a5..50082f37de 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt @@ -172,7 +172,6 @@ internal fun OnboardingPaymentDestination(viewModel: OnboardingPaymentViewModel, .align(Alignment.CenterHorizontally) .padding(horizontal = 32.dp), ) - Spacer(Modifier.height(16.dp)) OnboardingStepButtons( primaryText = stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_TITLE), onPrimaryClick = { viewModel.emit(OnboardingPaymentEvent.ConnectPayment) }, @@ -192,7 +191,6 @@ internal fun OnboardingPaymentDestination(viewModel: OnboardingPaymentViewModel, color = HedvigTheme.colorScheme.textSecondary, modifier = Modifier.align(Alignment.CenterHorizontally), ) - Spacer(Modifier.height(16.dp)) OnboardingStepButtons( primaryText = stringResource(Res.string.general_continue_button), onPrimaryClick = { viewModel.emit(OnboardingPaymentEvent.Continue) }, diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdDestination.kt index 05411980f2..e1b5040f85 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdDestination.kt @@ -211,7 +211,6 @@ internal fun OnboardingPetIdDestination( .fillMaxWidth() .padding(horizontal = 16.dp), ) - Spacer(Modifier.height(16.dp)) OnboardingStepButtons( primaryText = stringResource(Res.string.general_continue_button), onPrimaryClick = { viewModel.emit(OnboardingPetIdEvent.Continue) }, diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt index e8216df0b0..23cc2afa03 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt @@ -23,9 +23,14 @@ import com.hedvig.android.design.system.hedvig.HedvigErrorSection import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgressDebounced import com.hedvig.android.design.system.hedvig.HedvigText import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.IconResource import com.hedvig.android.design.system.hedvig.RadioGroup import com.hedvig.android.design.system.hedvig.RadioOption import com.hedvig.android.design.system.hedvig.RadioOptionId +import com.hedvig.android.design.system.hedvig.icon.CircleFilled +import com.hedvig.android.design.system.hedvig.icon.CircleOutline +import com.hedvig.android.design.system.hedvig.icon.HedvigIcons +import com.hedvig.android.design.system.hedvig.icon.Settings import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId @@ -167,16 +172,19 @@ internal fun OnboardingThemeDestination(viewModel: OnboardingThemeViewModel, nav id = RadioOptionId(Theme.SYSTEM_DEFAULT.name), text = stringResource(Res.string.SETTINGS_THEME_SYSTEM_DEFAULT), label = stringResource(Res.string.ONBOARDING_THEME_SYSTEM_SUBTITLE), + iconResource = IconResource.Vector(HedvigIcons.Settings), ), RadioOption( id = RadioOptionId(Theme.LIGHT.name), text = stringResource(Res.string.SETTINGS_THEME_LIGHT), label = stringResource(Res.string.ONBOARDING_THEME_LIGHT_SUBTITLE), + iconResource = IconResource.Vector(HedvigIcons.CircleOutline), ), RadioOption( id = RadioOptionId(Theme.DARK.name), text = stringResource(Res.string.SETTINGS_THEME_DARK), label = stringResource(Res.string.ONBOARDING_THEME_DARK_SUBTITLE), + iconResource = IconResource.Vector(HedvigIcons.CircleFilled), ), ), selectedOption = RadioOptionId(content.selectedTheme.name), @@ -195,7 +203,6 @@ internal fun OnboardingThemeDestination(viewModel: OnboardingThemeViewModel, nav .fillMaxWidth() .padding(horizontal = 16.dp), ) - Spacer(Modifier.height(16.dp)) OnboardingStepButtons( primaryText = stringResource(Res.string.general_continue_button), onPrimaryClick = { viewModel.emit(OnboardingThemeEvent.Continue) }, From 8f2d212747c17be255a798146c2a6f0713e23ccf Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Mon, 27 Jul 2026 17:23:34 +0200 Subject: [PATCH 045/119] Only claim payment connected when the payin method is active --- .../feature/onboarding/data/OnboardingData.kt | 16 ++- .../onboarding/data/OnboardingRepository.kt | 14 ++- .../payment/OnboardingPaymentDestination.kt | 101 +++++++++++------- .../onboarding/FakeOnboardingRepository.kt | 5 +- .../onboarding/data/OnboardingPathTest.kt | 3 +- .../payment/OnboardingPaymentPresenterTest.kt | 29 ++++- 6 files changed, 116 insertions(+), 52 deletions(-) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingData.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingData.kt index 1c49a221bc..48639e9420 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingData.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingData.kt @@ -9,7 +9,7 @@ internal data class OnboardingData( val phoneNumber: String?, val contracts: List, val referralInformation: OnboardingReferralInformation?, - val hasConnectedPayinMethod: Boolean, + val payinStatus: OnboardingPayinStatus, val crossSells: List, ) { val contractsWithMissingCoInsured: List = @@ -20,6 +20,20 @@ internal data class OnboardingData( val hasOnlyAccidentContracts: Boolean = contracts.isNotEmpty() && contracts.all { it.typeOfContract.contains("ACCIDENT") } + + /** + * Whether the connect-payment step should be skipped. A [OnboardingPayinStatus.Pending] method + * counts as connected here so we do not re-prompt during the multi-day bank activation wait; the + * step UI still distinguishes pending from active so it never falsely claims "connected". + */ + val hasConnectedPayinMethod: Boolean = payinStatus != OnboardingPayinStatus.NeedsSetup +} + +/** Mirrors the backend `MemberPaymentConnectionStatus`. */ +internal enum class OnboardingPayinStatus { + Active, + Pending, + NeedsSetup, } internal data class OnboardingContract( diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingRepository.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingRepository.kt index 00cdc1d233..eb69c49284 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingRepository.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingRepository.kt @@ -53,9 +53,17 @@ internal class OnboardingRepositoryImpl( currencyCode = referralInformation.monthlyDiscountPerReferral.currencyCode.rawValue, ) }, - // Deliberately no status filter: a PENDING payin method counts as connected, since bank - // connections take several bank days to activate and re-prompting mid-wait would be wrong. - hasConnectedPayinMethod = member.paymentMethods.payinMethods.isNotEmpty(), + payinStatus = member.paymentMethods.payinMethods.map { it.status.rawValue }.let { statuses -> + when { + statuses.any { it == "ACTIVE" } -> OnboardingPayinStatus.Active + + // A PENDING method counts as "connected enough" to skip the step (bank activation takes + // days), but the step UI still shows it as pending rather than claiming it is connected. + statuses.any { it == "PENDING" } -> OnboardingPayinStatus.Pending + + else -> OnboardingPayinStatus.NeedsSetup + } + }, crossSells = member.crossSellV2.otherCrossSells.map { crossSell -> OnboardingCrossSell( id = crossSell.id, diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt index 50082f37de..49bf8c9e5a 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt @@ -22,6 +22,7 @@ import com.hedvig.android.design.system.hedvig.HedvigErrorSection import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgressDebounced import com.hedvig.android.design.system.hedvig.HedvigText import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.feature.onboarding.data.OnboardingPayinStatus import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId @@ -71,7 +72,7 @@ internal class OnboardingPaymentPresenter( ifRight = { session -> currentState = OnboardingPaymentUiState.Content( progress = session.progressFor(OnboardingStepId.ConnectPayment), - isConnected = session.data.hasConnectedPayinMethod, + payinStatus = session.data.payinStatus, ) }, ) @@ -87,7 +88,7 @@ internal class OnboardingPaymentPresenter( sessionStore.refreshData().onRight { refreshed -> currentState = OnboardingPaymentUiState.Content( progress = refreshed.progressFor(OnboardingStepId.ConnectPayment), - isConnected = refreshed.data.hasConnectedPayinMethod, + payinStatus = refreshed.data.payinStatus, ) } } @@ -109,7 +110,7 @@ internal sealed interface OnboardingPaymentUiState { data class Content( val progress: OnboardingProgress, - val isConnected: Boolean, + val payinStatus: OnboardingPayinStatus, ) : OnboardingPaymentUiState } @@ -158,43 +159,63 @@ internal fun OnboardingPaymentDestination(viewModel: OnboardingPaymentViewModel, is OnboardingPaymentUiState.Content -> { Spacer(Modifier.height(16.dp)) - if (!content.isConnected) { - OnboardingStepHeader( - title = stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_TITLE), - description = stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_SUBTITLE), - ) - Spacer(Modifier.weight(1f)) - HedvigText( - text = stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_FOOTNOTE), - style = HedvigTheme.typography.finePrint, - color = HedvigTheme.colorScheme.textSecondary, - modifier = Modifier - .align(Alignment.CenterHorizontally) - .padding(horizontal = 32.dp), - ) - OnboardingStepButtons( - primaryText = stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_TITLE), - onPrimaryClick = { viewModel.emit(OnboardingPaymentEvent.ConnectPayment) }, - ) - } else { - OnboardingStepHeader( - title = stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_TITLE), - // TODO: Add "Your payment method is connected" / "Din betalmetod är kopplad" to Lokalise - description = "Your payment method is connected", - ) - Spacer(Modifier.weight(1f)) - // TODO: Add "You can switch accounts later in settings" / - // "Du kan byta konto senare i inställningar" to Lokalise - HedvigText( - text = "You can switch accounts later in settings", - style = HedvigTheme.typography.finePrint, - color = HedvigTheme.colorScheme.textSecondary, - modifier = Modifier.align(Alignment.CenterHorizontally), - ) - OnboardingStepButtons( - primaryText = stringResource(Res.string.general_continue_button), - onPrimaryClick = { viewModel.emit(OnboardingPaymentEvent.Continue) }, - ) + when (content.payinStatus) { + OnboardingPayinStatus.NeedsSetup -> { + OnboardingStepHeader( + title = stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_TITLE), + description = stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_SUBTITLE), + ) + Spacer(Modifier.weight(1f)) + HedvigText( + text = stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_FOOTNOTE), + style = HedvigTheme.typography.finePrint, + color = HedvigTheme.colorScheme.textSecondary, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(horizontal = 32.dp), + ) + OnboardingStepButtons( + primaryText = stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_TITLE), + onPrimaryClick = { viewModel.emit(OnboardingPaymentEvent.ConnectPayment) }, + ) + } + + OnboardingPayinStatus.Pending -> { + OnboardingStepHeader( + title = stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_TITLE), + // A method exists but is awaiting bank activation, so we must not claim it is + // connected. This can take a few days. + // TODO: Add "Your payment method is being set up. This can take a few days." / + // "Din betalmetod hålller på att sättas upp. Det kan ta några dagar." to Lokalise + description = "Your payment method is being set up. This can take a few days.", + ) + Spacer(Modifier.weight(1f)) + OnboardingStepButtons( + primaryText = stringResource(Res.string.general_continue_button), + onPrimaryClick = { viewModel.emit(OnboardingPaymentEvent.Continue) }, + ) + } + + OnboardingPayinStatus.Active -> { + OnboardingStepHeader( + title = stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_TITLE), + // TODO: Add "Your payment method is connected" / "Din betalmetod är kopplad" to Lokalise + description = "Your payment method is connected", + ) + Spacer(Modifier.weight(1f)) + // TODO: Add "You can switch accounts later in settings" / + // "Du kan byta konto senare i inställningar" to Lokalise + HedvigText( + text = "You can switch accounts later in settings", + style = HedvigTheme.typography.finePrint, + color = HedvigTheme.colorScheme.textSecondary, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + OnboardingStepButtons( + primaryText = stringResource(Res.string.general_continue_button), + onPrimaryClick = { viewModel.emit(OnboardingPaymentEvent.Continue) }, + ) + } } } } diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/FakeOnboardingRepository.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/FakeOnboardingRepository.kt index 2dde9c50c2..15089dd667 100644 --- a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/FakeOnboardingRepository.kt +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/FakeOnboardingRepository.kt @@ -7,6 +7,7 @@ import com.hedvig.android.feature.onboarding.data.OnboardingContract import com.hedvig.android.feature.onboarding.data.OnboardingCrossSell import com.hedvig.android.feature.onboarding.data.OnboardingData import com.hedvig.android.feature.onboarding.data.OnboardingMemberIdProvider +import com.hedvig.android.feature.onboarding.data.OnboardingPayinStatus import com.hedvig.android.feature.onboarding.data.OnboardingReferralInformation import com.hedvig.android.feature.onboarding.data.OnboardingRepository import kotlinx.coroutines.flow.flowOf @@ -43,7 +44,7 @@ internal fun testOnboardingData( ), ), referralInformation: OnboardingReferralInformation? = OnboardingReferralInformation("CODE", 10.0, "SEK"), - hasConnectedPayinMethod: Boolean = false, + payinStatus: OnboardingPayinStatus = OnboardingPayinStatus.NeedsSetup, crossSells: List = listOf( OnboardingCrossSell( id = "cs", @@ -58,6 +59,6 @@ internal fun testOnboardingData( phoneNumber = phoneNumber, contracts = contracts, referralInformation = referralInformation, - hasConnectedPayinMethod = hasConnectedPayinMethod, + payinStatus = payinStatus, crossSells = crossSells, ) diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingPathTest.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingPathTest.kt index 8e39ca165e..1a1052c42b 100644 --- a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingPathTest.kt +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingPathTest.kt @@ -4,6 +4,7 @@ import assertk.assertThat import assertk.assertions.contains import assertk.assertions.containsExactly import assertk.assertions.doesNotContain +import com.hedvig.android.feature.onboarding.data.OnboardingPayinStatus import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId import org.junit.Test @@ -40,7 +41,7 @@ class OnboardingPathTest { phoneNumber = "070 990 12 32", contracts = contracts, referralInformation = referralInformation, - hasConnectedPayinMethod = hasConnectedPayinMethod, + payinStatus = if (hasConnectedPayinMethod) OnboardingPayinStatus.Active else OnboardingPayinStatus.NeedsSetup, crossSells = crossSells, ) diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentPresenterTest.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentPresenterTest.kt index 1c0c46f351..04357a2441 100644 --- a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentPresenterTest.kt +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentPresenterTest.kt @@ -9,6 +9,7 @@ import com.hedvig.android.feature.connect.payment.trustly.ui.TrustlyKey import com.hedvig.android.feature.onboarding.FakeOnboardingMemberIdProvider import com.hedvig.android.feature.onboarding.FakeOnboardingRepository import com.hedvig.android.feature.onboarding.data.CompleteOnboardingUseCase +import com.hedvig.android.feature.onboarding.data.OnboardingPayinStatus import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId @@ -45,11 +46,11 @@ internal class OnboardingPaymentPresenterTest { presenter.test(OnboardingPaymentUiState.Loading) { skipItems(1) - repository.onboardingDataResponses.add(testOnboardingData(hasConnectedPayinMethod = false).right()) + repository.onboardingDataResponses.add(testOnboardingData(payinStatus = OnboardingPayinStatus.NeedsSetup).right()) val state = awaitItem() assertThat(state).isInstanceOf() val content = state as OnboardingPaymentUiState.Content - assertThat(content.isConnected).isEqualTo(false) + assertThat(content.payinStatus).isEqualTo(OnboardingPayinStatus.NeedsSetup) } } @@ -72,7 +73,7 @@ internal class OnboardingPaymentPresenterTest { } @Test - fun `refresh after returning flips to connected`() = runTest { + fun `refresh after returning with an active method flips to connected`() = runTest { val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.ConnectPayment)) } val repository = FakeOnboardingRepository() val sessionStore = OnboardingSessionStore(repository, FakeOnboardingMemberIdProvider()) @@ -85,11 +86,29 @@ internal class OnboardingPaymentPresenterTest { awaitItem() sendEvent(OnboardingPaymentEvent.Refresh) runCurrent() - repository.onboardingDataResponses.add(testOnboardingData(hasConnectedPayinMethod = true).right()) + repository.onboardingDataResponses.add(testOnboardingData(payinStatus = OnboardingPayinStatus.Active).right()) val state = awaitItem() assertThat(state).isInstanceOf() val content = state as OnboardingPaymentUiState.Content - assertThat(content.isConnected).isEqualTo(true) + assertThat(content.payinStatus).isEqualTo(OnboardingPayinStatus.Active) + } + } + + @Test + fun `a pending method is shown as pending, not connected`() = runTest { + val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.ConnectPayment)) } + val repository = FakeOnboardingRepository() + val sessionStore = OnboardingSessionStore(repository, FakeOnboardingMemberIdProvider()) + val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) + val presenter = OnboardingPaymentPresenter(sessionStore, navigator) + + presenter.test(OnboardingPaymentUiState.Loading) { + skipItems(1) + repository.onboardingDataResponses.add(testOnboardingData(payinStatus = OnboardingPayinStatus.Pending).right()) + val state = awaitItem() + assertThat(state).isInstanceOf() + val content = state as OnboardingPaymentUiState.Content + assertThat(content.payinStatus).isEqualTo(OnboardingPayinStatus.Pending) } } From 011ae37753d3299cbcbc508130e4f3fe955425cb Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Mon, 27 Jul 2026 19:14:51 +0200 Subject: [PATCH 046/119] Wait for real flag value before deciding onboarding The onboarding gate read the DISABLE_ONBOARDING kill switch via isFeatureEnabled(...).first(), which is the immediate pre-fetch snapshot from Unleash. On a cold start (after a force-kill) neither the on-disk backup restore nor a network fetch has landed yet, so the snapshot is the empty default (switch off) and onboarding was shown even when the switch was on remotely. Warm resume worked only because the still-alive process had already fetched the real value. Add FeatureManager.awaitReady(), backed by the Unleash SDK's isReady()/UnleashReadyListener, which completes once a real toggle set is available from either the disk backup or a fresh fetch. The gate now awaits it (with a timeout) before reading the switch: a returning member's persisted value is restored within milliseconds, a late fetch just delays the on-top-of-Home push a few frames, and a fresh offline install with no backup falls through after the timeout to the natural default (onboarding shown), which is correct for a brand-new member. --- .../feature/onboarding/gate/OnboardingGate.kt | 12 +++++++++ .../featureflags/test/FakeFeatureManager.kt | 2 ++ .../featureflags/HedvigUnleashClient.kt | 25 +++++++++++++++++++ .../flags/UnleashFeatureFlagProvider.kt | 4 +++ .../android/featureflags/FeatureManager.kt | 9 +++++++ .../featureflags/di/IosFeatureManager.kt | 2 ++ .../flags/NoopFeatureFlagProvider.kt | 2 ++ 7 files changed, 56 insertions(+) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/gate/OnboardingGate.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/gate/OnboardingGate.kt index 2162825765..8fc82a2d8d 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/gate/OnboardingGate.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/gate/OnboardingGate.kt @@ -15,7 +15,9 @@ import com.hedvig.android.navigation.compose.Backstack import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn +import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull /** * Decides whether onboarding should be shown for the current member: not killed by the @@ -48,6 +50,12 @@ internal class OnboardingGateImpl( private val completeOnboardingUseCase: CompleteOnboardingUseCase, ) : OnboardingGate { override suspend fun shouldShowOnboarding(): Boolean { + // The kill switch must reflect the remote value, not the empty pre-fetch default a cold start would + // otherwise read, so wait for a real flag value (restored from disk or freshly fetched) before + // trusting it. A returning member's value is restored within milliseconds; on a fresh offline install + // with no backup it never arrives, so after the timeout we fall through to the pre-fetch default + // (switch off -> onboarding shown), which is the right outcome for a brand-new member. + withTimeoutOrNull(FLAG_READY_TIMEOUT) { featureManager.awaitReady() } if (featureManager.isFeatureEnabled(Feature.DISABLE_ONBOARDING).first()) { logcat(LogPriority.INFO) { "Onboarding is disabled by the kill switch, not showing onboarding" } return false @@ -71,3 +79,7 @@ internal class OnboardingGateImpl( completeOnboardingUseCase.invoke() } } + +// Comfortably covers a local backup restore (milliseconds) and a first network fetch (poll interval is +// 2s), while bounding the wait so a fully offline fresh install still resolves promptly. +private val FLAG_READY_TIMEOUT = 5.seconds diff --git a/app/featureflags/feature-flags-test/src/main/kotlin/com/hedvig/android/featureflags/test/FakeFeatureManager.kt b/app/featureflags/feature-flags-test/src/main/kotlin/com/hedvig/android/featureflags/test/FakeFeatureManager.kt index 04f5df6c5b..e4b97ae7ef 100644 --- a/app/featureflags/feature-flags-test/src/main/kotlin/com/hedvig/android/featureflags/test/FakeFeatureManager.kt +++ b/app/featureflags/feature-flags-test/src/main/kotlin/com/hedvig/android/featureflags/test/FakeFeatureManager.kt @@ -31,4 +31,6 @@ class FakeFeatureManager( emit(pair.second) } } + + override suspend fun awaitReady() = Unit } diff --git a/app/featureflags/feature-flags/src/androidMain/kotlin/com/hedvig/android/featureflags/HedvigUnleashClient.kt b/app/featureflags/feature-flags/src/androidMain/kotlin/com/hedvig/android/featureflags/HedvigUnleashClient.kt index 0521494edd..e6ace8af58 100644 --- a/app/featureflags/feature-flags/src/androidMain/kotlin/com/hedvig/android/featureflags/HedvigUnleashClient.kt +++ b/app/featureflags/feature-flags/src/androidMain/kotlin/com/hedvig/android/featureflags/HedvigUnleashClient.kt @@ -11,12 +11,15 @@ import io.getunleash.android.data.Toggle import io.getunleash.android.data.UnleashContext import io.getunleash.android.events.HeartbeatEvent import io.getunleash.android.events.UnleashFetcherHeartbeatListener +import io.getunleash.android.events.UnleashReadyListener +import kotlin.coroutines.resume import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine private const val PRODUCTION_CLIENT_KEY = "*:production.21d6af57ae16320fde3a3caf024162db19cc33bf600ab7439c865c20" private const val DEVELOPMENT_CLIENT_KEY = "*:development.f2455340ac9d599b5816fa879d079f21dd0eb03e4315130deb5377b6" @@ -58,6 +61,28 @@ class HedvigUnleashClient( } } + /** + * Suspends until the SDK holds a real toggle set for the current context, restored from the on-disk + * backup of the last fetch or fetched fresh from the network. Returns immediately if that already + * happened. On a fresh install that is fully offline with no backup this never completes, so callers + * must wrap it in their own timeout. + */ + suspend fun awaitReady() { + if (client.isReady()) return + suspendCancellableCoroutine { continuation -> + val listener = object : UnleashReadyListener { + override fun onReady() { + if (continuation.isActive) continuation.resume(Unit) + } + } + client.addUnleashEventListener(listener) + // Guard the window between the isReady() check above and registering the listener, where onReady + // could have fired with no one listening. + if (client.isReady() && continuation.isActive) continuation.resume(Unit) + continuation.invokeOnCancellation { client.removeUnleashEventListener(listener) } + } + } + init { coroutineScope.launch { memberIdService.getMemberId().collectLatest { memberId: String? -> diff --git a/app/featureflags/feature-flags/src/androidMain/kotlin/com/hedvig/android/featureflags/flags/UnleashFeatureFlagProvider.kt b/app/featureflags/feature-flags/src/androidMain/kotlin/com/hedvig/android/featureflags/flags/UnleashFeatureFlagProvider.kt index f5c2e694c3..5adb25bd7a 100644 --- a/app/featureflags/feature-flags/src/androidMain/kotlin/com/hedvig/android/featureflags/flags/UnleashFeatureFlagProvider.kt +++ b/app/featureflags/feature-flags/src/androidMain/kotlin/com/hedvig/android/featureflags/flags/UnleashFeatureFlagProvider.kt @@ -23,4 +23,8 @@ internal class UnleashFeatureFlagProvider( .map { hedvigUnleashClient.client.isEnabled(feature.unleashKey) } .distinctUntilChanged() } + + override suspend fun awaitReady() { + hedvigUnleashClient.awaitReady() + } } diff --git a/app/featureflags/feature-flags/src/commonMain/kotlin/com/hedvig/android/featureflags/FeatureManager.kt b/app/featureflags/feature-flags/src/commonMain/kotlin/com/hedvig/android/featureflags/FeatureManager.kt index c475b01ce4..0a38b443fb 100644 --- a/app/featureflags/feature-flags/src/commonMain/kotlin/com/hedvig/android/featureflags/FeatureManager.kt +++ b/app/featureflags/feature-flags/src/commonMain/kotlin/com/hedvig/android/featureflags/FeatureManager.kt @@ -7,4 +7,13 @@ import kotlinx.coroutines.flow.Flow interface FeatureManager { @NativeCoroutines fun isFeatureEnabled(feature: Feature): Flow + + /** + * Suspends until a real flag value is available for the current member, sourced from either the + * on-disk backup of the last fetch or a fresh network fetch. Before this completes, [isFeatureEnabled] + * reports only pre-fetch defaults, so a decision that must honor the remote value (for example a kill + * switch gating a whole flow) should await this first. On a fresh install that is fully offline with no + * backup this never completes, so callers must impose their own timeout and fall back to defaults. + */ + suspend fun awaitReady() } diff --git a/app/featureflags/feature-flags/src/iosMain/kotlin/com/hedvig/android/featureflags/di/IosFeatureManager.kt b/app/featureflags/feature-flags/src/iosMain/kotlin/com/hedvig/android/featureflags/di/IosFeatureManager.kt index 1e83c24fb2..abe6d5e9f3 100644 --- a/app/featureflags/feature-flags/src/iosMain/kotlin/com/hedvig/android/featureflags/di/IosFeatureManager.kt +++ b/app/featureflags/feature-flags/src/iosMain/kotlin/com/hedvig/android/featureflags/di/IosFeatureManager.kt @@ -11,4 +11,6 @@ class IosFeatureManager(private val isFeatureEnabledBlock: (Feature) -> Boolean) override fun isFeatureEnabled(feature: Feature): Flow = flow { emit(withContext(Dispatchers.Main.immediate) { isFeatureEnabledBlock(feature) }) } + + override suspend fun awaitReady() = Unit } diff --git a/app/featureflags/feature-flags/src/jvmMain/kotlin/com/hedvig/android/featureflags/flags/NoopFeatureFlagProvider.kt b/app/featureflags/feature-flags/src/jvmMain/kotlin/com/hedvig/android/featureflags/flags/NoopFeatureFlagProvider.kt index 3033fdafd7..c3c8c25ff9 100644 --- a/app/featureflags/feature-flags/src/jvmMain/kotlin/com/hedvig/android/featureflags/flags/NoopFeatureFlagProvider.kt +++ b/app/featureflags/feature-flags/src/jvmMain/kotlin/com/hedvig/android/featureflags/flags/NoopFeatureFlagProvider.kt @@ -8,4 +8,6 @@ internal class NoopFeatureFlagProvider : FeatureManager { override fun isFeatureEnabled(feature: Feature): Flow { return flowOf(false) } + + override suspend fun awaitReady() = Unit } From 7a16a2708b7c1dca36b58f9753edde6acb9c07a4 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 28 Jul 2026 11:19:28 +0200 Subject: [PATCH 047/119] Only show onboarding when the flag backend is reachable Fail closed when the flag backend cannot be reached, rather than trusting a stale on-disk backup or a pre-fetch default. Previously, with internet up but Unleash unreachable (outage), the onboarding session fetch to Octopus would succeed while the kill switch read as its empty default, so onboarding showed even though we never obtained a real flag value. Replace FeatureManager.awaitReady() with awaitFlagsFromServer(), which completes only once Unleash confirms the toggle state directly with the server this session: a fresh fetch (togglesUpdated / HTTP 200) or a confirmation that the cached set is unchanged (togglesChecked / HTTP 304). A backup restore or a failed fetch does not complete it. The gate awaits it under a timeout and shows nothing (without marking onboarding seen) when it does not complete, so: - No internet: fail closed; a later online launch decides properly. - Internet up, Unleash outage: fail closed, same as offline. - Backend reachable: read the real flag and react. The wait runs behind an already-rendered Home, so it is not user visible, and this is also more robust for the recently-flipped-flag case, since it waits for the confirmed value instead of a stale backup. --- .../feature/onboarding/gate/OnboardingGate.kt | 25 ++++++---- .../featureflags/test/FakeFeatureManager.kt | 2 +- .../featureflags/HedvigUnleashClient.kt | 48 +++++++++++-------- .../flags/UnleashFeatureFlagProvider.kt | 4 +- .../android/featureflags/FeatureManager.kt | 12 ++--- .../featureflags/di/IosFeatureManager.kt | 2 +- .../flags/NoopFeatureFlagProvider.kt | 2 +- 7 files changed, 54 insertions(+), 41 deletions(-) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/gate/OnboardingGate.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/gate/OnboardingGate.kt index 8fc82a2d8d..d0a2b45c0e 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/gate/OnboardingGate.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/gate/OnboardingGate.kt @@ -50,12 +50,18 @@ internal class OnboardingGateImpl( private val completeOnboardingUseCase: CompleteOnboardingUseCase, ) : OnboardingGate { override suspend fun shouldShowOnboarding(): Boolean { - // The kill switch must reflect the remote value, not the empty pre-fetch default a cold start would - // otherwise read, so wait for a real flag value (restored from disk or freshly fetched) before - // trusting it. A returning member's value is restored within milliseconds; on a fresh offline install - // with no backup it never arrives, so after the timeout we fall through to the pre-fetch default - // (switch off -> onboarding shown), which is the right outcome for a brand-new member. - withTimeoutOrNull(FLAG_READY_TIMEOUT) { featureManager.awaitReady() } + // Only decide from a flag value the backend has actually confirmed this session. When it is + // unreachable (offline, or an Unleash outage) the confirmation never arrives; we fail closed and + // show nothing, without marking onboarding seen, so a later launch that reaches the backend decides + // properly. This runs after Home is already on screen, so the wait is not user visible. + val flagsConfirmed = withTimeoutOrNull(FLAG_CONFIRMATION_TIMEOUT) { + featureManager.awaitFlagsFromServer() + true + } ?: false + if (!flagsConfirmed) { + logcat(LogPriority.INFO) { "Flag backend not reachable, not showing onboarding this launch" } + return false + } if (featureManager.isFeatureEnabled(Feature.DISABLE_ONBOARDING).first()) { logcat(LogPriority.INFO) { "Onboarding is disabled by the kill switch, not showing onboarding" } return false @@ -80,6 +86,7 @@ internal class OnboardingGateImpl( } } -// Comfortably covers a local backup restore (milliseconds) and a first network fetch (poll interval is -// 2s), while bounding the wait so a fully offline fresh install still resolves promptly. -private val FLAG_READY_TIMEOUT = 5.seconds +// Comfortably covers a first successful Unleash poll (poll interval is 2s) plus network latency. When +// the backend is unreachable this whole window is spent before failing closed, but it runs behind an +// already-rendered Home, so it is not user visible. +private val FLAG_CONFIRMATION_TIMEOUT = 5.seconds diff --git a/app/featureflags/feature-flags-test/src/main/kotlin/com/hedvig/android/featureflags/test/FakeFeatureManager.kt b/app/featureflags/feature-flags-test/src/main/kotlin/com/hedvig/android/featureflags/test/FakeFeatureManager.kt index e4b97ae7ef..891465ed38 100644 --- a/app/featureflags/feature-flags-test/src/main/kotlin/com/hedvig/android/featureflags/test/FakeFeatureManager.kt +++ b/app/featureflags/feature-flags-test/src/main/kotlin/com/hedvig/android/featureflags/test/FakeFeatureManager.kt @@ -32,5 +32,5 @@ class FakeFeatureManager( } } - override suspend fun awaitReady() = Unit + override suspend fun awaitFlagsFromServer() = Unit } diff --git a/app/featureflags/feature-flags/src/androidMain/kotlin/com/hedvig/android/featureflags/HedvigUnleashClient.kt b/app/featureflags/feature-flags/src/androidMain/kotlin/com/hedvig/android/featureflags/HedvigUnleashClient.kt index e6ace8af58..55a1347833 100644 --- a/app/featureflags/feature-flags/src/androidMain/kotlin/com/hedvig/android/featureflags/HedvigUnleashClient.kt +++ b/app/featureflags/feature-flags/src/androidMain/kotlin/com/hedvig/android/featureflags/HedvigUnleashClient.kt @@ -11,15 +11,14 @@ import io.getunleash.android.data.Toggle import io.getunleash.android.data.UnleashContext import io.getunleash.android.events.HeartbeatEvent import io.getunleash.android.events.UnleashFetcherHeartbeatListener -import io.getunleash.android.events.UnleashReadyListener -import kotlin.coroutines.resume import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine private const val PRODUCTION_CLIENT_KEY = "*:production.21d6af57ae16320fde3a3caf024162db19cc33bf600ab7439c865c20" private const val DEVELOPMENT_CLIENT_KEY = "*:development.f2455340ac9d599b5816fa879d079f21dd0eb03e4315130deb5377b6" @@ -61,26 +60,20 @@ class HedvigUnleashClient( } } + // Flipped once Unleash confirms the toggle state directly with the server this session, whether it + // fetched a new set (togglesUpdated) or confirmed the current one is unchanged (togglesChecked, an + // HTTP 304). It is NOT flipped by a restore from the on-disk backup or by a failed fetch, so it stays + // false while offline or during an Unleash outage. + private val serverConfirmed = MutableStateFlow(false) + /** - * Suspends until the SDK holds a real toggle set for the current context, restored from the on-disk - * backup of the last fetch or fetched fresh from the network. Returns immediately if that already - * happened. On a fresh install that is fully offline with no backup this never completes, so callers - * must wrap it in their own timeout. + * Suspends until Unleash has confirmed the toggle state with the server this session, meaning the + * flag values are authoritative right now. Returns immediately once that has happened. While offline + * or during an Unleash outage it never completes, so callers must wrap it in a timeout and treat the + * timeout as "no authoritative flag value available". */ - suspend fun awaitReady() { - if (client.isReady()) return - suspendCancellableCoroutine { continuation -> - val listener = object : UnleashReadyListener { - override fun onReady() { - if (continuation.isActive) continuation.resume(Unit) - } - } - client.addUnleashEventListener(listener) - // Guard the window between the isReady() check above and registering the listener, where onReady - // could have fired with no one listening. - if (client.isReady() && continuation.isActive) continuation.resume(Unit) - continuation.invokeOnCancellation { client.removeUnleashEventListener(listener) } - } + suspend fun awaitServerResponse() { + serverConfirmed.first { it } } init { @@ -94,6 +87,19 @@ class HedvigUnleashClient( ) } } + client.addUnleashEventListener( + object : UnleashFetcherHeartbeatListener { + override fun onError(event: HeartbeatEvent) {} + + override fun togglesChecked() { + serverConfirmed.value = true + } + + override fun togglesUpdated() { + serverConfirmed.value = true + } + }, + ) // Bootstrap the puppy guide kill switch to on, so the feature stays hidden until the first // successful fetch. Once toggles are fetched, the remote value takes over. client.start(bootstrap = listOf(Toggle(name = Feature.DISABLE_PUPPY_GUIDE.unleashKey, enabled = true))) diff --git a/app/featureflags/feature-flags/src/androidMain/kotlin/com/hedvig/android/featureflags/flags/UnleashFeatureFlagProvider.kt b/app/featureflags/feature-flags/src/androidMain/kotlin/com/hedvig/android/featureflags/flags/UnleashFeatureFlagProvider.kt index 5adb25bd7a..2772fec796 100644 --- a/app/featureflags/feature-flags/src/androidMain/kotlin/com/hedvig/android/featureflags/flags/UnleashFeatureFlagProvider.kt +++ b/app/featureflags/feature-flags/src/androidMain/kotlin/com/hedvig/android/featureflags/flags/UnleashFeatureFlagProvider.kt @@ -24,7 +24,7 @@ internal class UnleashFeatureFlagProvider( .distinctUntilChanged() } - override suspend fun awaitReady() { - hedvigUnleashClient.awaitReady() + override suspend fun awaitFlagsFromServer() { + hedvigUnleashClient.awaitServerResponse() } } diff --git a/app/featureflags/feature-flags/src/commonMain/kotlin/com/hedvig/android/featureflags/FeatureManager.kt b/app/featureflags/feature-flags/src/commonMain/kotlin/com/hedvig/android/featureflags/FeatureManager.kt index 0a38b443fb..efbd1701b6 100644 --- a/app/featureflags/feature-flags/src/commonMain/kotlin/com/hedvig/android/featureflags/FeatureManager.kt +++ b/app/featureflags/feature-flags/src/commonMain/kotlin/com/hedvig/android/featureflags/FeatureManager.kt @@ -9,11 +9,11 @@ interface FeatureManager { fun isFeatureEnabled(feature: Feature): Flow /** - * Suspends until a real flag value is available for the current member, sourced from either the - * on-disk backup of the last fetch or a fresh network fetch. Before this completes, [isFeatureEnabled] - * reports only pre-fetch defaults, so a decision that must honor the remote value (for example a kill - * switch gating a whole flow) should await this first. On a fresh install that is fully offline with no - * backup this never completes, so callers must impose their own timeout and fall back to defaults. + * Suspends until the flag values are confirmed authoritative for the current session: the backend has + * been reached and its current values are in effect, not a stale local fallback or a pre-fetch default. + * A decision that must honor the remote value, for example a kill switch gating a whole flow, should + * await this and treat a failure to complete as "value unknown". While the backend is unreachable + * (offline, or a backend outage) this never completes, so callers must impose their own timeout. */ - suspend fun awaitReady() + suspend fun awaitFlagsFromServer() } diff --git a/app/featureflags/feature-flags/src/iosMain/kotlin/com/hedvig/android/featureflags/di/IosFeatureManager.kt b/app/featureflags/feature-flags/src/iosMain/kotlin/com/hedvig/android/featureflags/di/IosFeatureManager.kt index abe6d5e9f3..db1d245029 100644 --- a/app/featureflags/feature-flags/src/iosMain/kotlin/com/hedvig/android/featureflags/di/IosFeatureManager.kt +++ b/app/featureflags/feature-flags/src/iosMain/kotlin/com/hedvig/android/featureflags/di/IosFeatureManager.kt @@ -12,5 +12,5 @@ class IosFeatureManager(private val isFeatureEnabledBlock: (Feature) -> Boolean) emit(withContext(Dispatchers.Main.immediate) { isFeatureEnabledBlock(feature) }) } - override suspend fun awaitReady() = Unit + override suspend fun awaitFlagsFromServer() = Unit } diff --git a/app/featureflags/feature-flags/src/jvmMain/kotlin/com/hedvig/android/featureflags/flags/NoopFeatureFlagProvider.kt b/app/featureflags/feature-flags/src/jvmMain/kotlin/com/hedvig/android/featureflags/flags/NoopFeatureFlagProvider.kt index c3c8c25ff9..01e32c364e 100644 --- a/app/featureflags/feature-flags/src/jvmMain/kotlin/com/hedvig/android/featureflags/flags/NoopFeatureFlagProvider.kt +++ b/app/featureflags/feature-flags/src/jvmMain/kotlin/com/hedvig/android/featureflags/flags/NoopFeatureFlagProvider.kt @@ -9,5 +9,5 @@ internal class NoopFeatureFlagProvider : FeatureManager { return flowOf(false) } - override suspend fun awaitReady() = Unit + override suspend fun awaitFlagsFromServer() = Unit } From a8a7ac7c812dc6d34f5dc46d5563911aa27a72a2 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 28 Jul 2026 14:12:44 +0200 Subject: [PATCH 048/119] Act on a cached flag value for the onboarding gate Base the onboarding decision on whether a flag value is available at all, from either the last fetch's on-disk backup or a fresh fetch, rather than requiring a fresh server confirmation this launch. Acting on the last-known value is acceptable: the only divergence is a member who has never been shown onboarding yet holds a stale "show" backup, sees it once during an outage right after the kill switch was flipped on, and self-corrects on the next successful fetch. That case is negligible and not worth blocking every returning user's decision on a live round-trip. The gate still fails closed when no value can be obtained within the timeout (fresh install offline, or an outage before the first fetch ever succeeded), showing nothing without marking onboarding seen so a later launch decides properly. Replace awaitFlagsFromServer()/the togglesUpdated+togglesChecked tracking with awaitReady() backed by the SDK's isReady()/onReady. --- .../feature/onboarding/gate/OnboardingGate.kt | 25 +++++----- .../featureflags/test/FakeFeatureManager.kt | 2 +- .../featureflags/HedvigUnleashClient.kt | 49 +++++++++---------- .../flags/UnleashFeatureFlagProvider.kt | 4 +- .../android/featureflags/FeatureManager.kt | 12 ++--- .../featureflags/di/IosFeatureManager.kt | 2 +- .../flags/NoopFeatureFlagProvider.kt | 2 +- 7 files changed, 46 insertions(+), 50 deletions(-) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/gate/OnboardingGate.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/gate/OnboardingGate.kt index d0a2b45c0e..6b0b8aed37 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/gate/OnboardingGate.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/gate/OnboardingGate.kt @@ -50,16 +50,17 @@ internal class OnboardingGateImpl( private val completeOnboardingUseCase: CompleteOnboardingUseCase, ) : OnboardingGate { override suspend fun shouldShowOnboarding(): Boolean { - // Only decide from a flag value the backend has actually confirmed this session. When it is - // unreachable (offline, or an Unleash outage) the confirmation never arrives; we fail closed and - // show nothing, without marking onboarding seen, so a later launch that reaches the backend decides - // properly. This runs after Home is already on screen, so the wait is not user visible. - val flagsConfirmed = withTimeoutOrNull(FLAG_CONFIRMATION_TIMEOUT) { - featureManager.awaitFlagsFromServer() + // Decide only once a flag value is available, whether freshly fetched or restored from the last + // fetch's on-disk backup. Until the app has ever reached Unleash there is no value (fresh install + // offline, or an outage before the first fetch); we then fail closed and show nothing without + // marking onboarding seen, so a later launch that gets a value decides properly. This runs after + // Home is already on screen, so the wait is not user visible. + val flagsAvailable = withTimeoutOrNull(FLAG_READY_TIMEOUT) { + featureManager.awaitReady() true } ?: false - if (!flagsConfirmed) { - logcat(LogPriority.INFO) { "Flag backend not reachable, not showing onboarding this launch" } + if (!flagsAvailable) { + logcat(LogPriority.INFO) { "No flag value available yet, not showing onboarding this launch" } return false } if (featureManager.isFeatureEnabled(Feature.DISABLE_ONBOARDING).first()) { @@ -86,7 +87,7 @@ internal class OnboardingGateImpl( } } -// Comfortably covers a first successful Unleash poll (poll interval is 2s) plus network latency. When -// the backend is unreachable this whole window is spent before failing closed, but it runs behind an -// already-rendered Home, so it is not user visible. -private val FLAG_CONFIRMATION_TIMEOUT = 5.seconds +// Comfortably covers a local backup restore (near-instant) and a first successful Unleash poll (poll +// interval is 2s) plus network latency. When no value can be obtained this whole window is spent before +// failing closed, but it runs behind an already-rendered Home, so it is not user visible. +private val FLAG_READY_TIMEOUT = 5.seconds diff --git a/app/featureflags/feature-flags-test/src/main/kotlin/com/hedvig/android/featureflags/test/FakeFeatureManager.kt b/app/featureflags/feature-flags-test/src/main/kotlin/com/hedvig/android/featureflags/test/FakeFeatureManager.kt index 891465ed38..e4b97ae7ef 100644 --- a/app/featureflags/feature-flags-test/src/main/kotlin/com/hedvig/android/featureflags/test/FakeFeatureManager.kt +++ b/app/featureflags/feature-flags-test/src/main/kotlin/com/hedvig/android/featureflags/test/FakeFeatureManager.kt @@ -32,5 +32,5 @@ class FakeFeatureManager( } } - override suspend fun awaitFlagsFromServer() = Unit + override suspend fun awaitReady() = Unit } diff --git a/app/featureflags/feature-flags/src/androidMain/kotlin/com/hedvig/android/featureflags/HedvigUnleashClient.kt b/app/featureflags/feature-flags/src/androidMain/kotlin/com/hedvig/android/featureflags/HedvigUnleashClient.kt index 55a1347833..d2b5c5a32d 100644 --- a/app/featureflags/feature-flags/src/androidMain/kotlin/com/hedvig/android/featureflags/HedvigUnleashClient.kt +++ b/app/featureflags/feature-flags/src/androidMain/kotlin/com/hedvig/android/featureflags/HedvigUnleashClient.kt @@ -11,14 +11,15 @@ import io.getunleash.android.data.Toggle import io.getunleash.android.data.UnleashContext import io.getunleash.android.events.HeartbeatEvent import io.getunleash.android.events.UnleashFetcherHeartbeatListener +import io.getunleash.android.events.UnleashReadyListener +import kotlin.coroutines.resume import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine private const val PRODUCTION_CLIENT_KEY = "*:production.21d6af57ae16320fde3a3caf024162db19cc33bf600ab7439c865c20" private const val DEVELOPMENT_CLIENT_KEY = "*:development.f2455340ac9d599b5816fa879d079f21dd0eb03e4315130deb5377b6" @@ -60,20 +61,27 @@ class HedvigUnleashClient( } } - // Flipped once Unleash confirms the toggle state directly with the server this session, whether it - // fetched a new set (togglesUpdated) or confirmed the current one is unchanged (togglesChecked, an - // HTTP 304). It is NOT flipped by a restore from the on-disk backup or by a failed fetch, so it stays - // false while offline or during an Unleash outage. - private val serverConfirmed = MutableStateFlow(false) - /** - * Suspends until Unleash has confirmed the toggle state with the server this session, meaning the - * flag values are authoritative right now. Returns immediately once that has happened. While offline - * or during an Unleash outage it never completes, so callers must wrap it in a timeout and treat the - * timeout as "no authoritative flag value available". + * Suspends until the SDK has flag values available for the current context, whether restored from the + * on-disk backup of the last successful fetch or freshly fetched from the network. Returns immediately + * if that has already happened. When the app has never fetched and cannot reach Unleash (a fresh + * install offline, or an outage before any fetch) there is nothing to restore either, so this never + * completes; callers must wrap it in a timeout and treat the timeout as "no flag value available yet". */ - suspend fun awaitServerResponse() { - serverConfirmed.first { it } + suspend fun awaitReady() { + if (client.isReady()) return + suspendCancellableCoroutine { continuation -> + val listener = object : UnleashReadyListener { + override fun onReady() { + if (continuation.isActive) continuation.resume(Unit) + } + } + client.addUnleashEventListener(listener) + // Guard the window between the isReady() check above and registering the listener, where onReady + // could have fired with no one listening. + if (client.isReady() && continuation.isActive) continuation.resume(Unit) + continuation.invokeOnCancellation { client.removeUnleashEventListener(listener) } + } } init { @@ -87,19 +95,6 @@ class HedvigUnleashClient( ) } } - client.addUnleashEventListener( - object : UnleashFetcherHeartbeatListener { - override fun onError(event: HeartbeatEvent) {} - - override fun togglesChecked() { - serverConfirmed.value = true - } - - override fun togglesUpdated() { - serverConfirmed.value = true - } - }, - ) // Bootstrap the puppy guide kill switch to on, so the feature stays hidden until the first // successful fetch. Once toggles are fetched, the remote value takes over. client.start(bootstrap = listOf(Toggle(name = Feature.DISABLE_PUPPY_GUIDE.unleashKey, enabled = true))) diff --git a/app/featureflags/feature-flags/src/androidMain/kotlin/com/hedvig/android/featureflags/flags/UnleashFeatureFlagProvider.kt b/app/featureflags/feature-flags/src/androidMain/kotlin/com/hedvig/android/featureflags/flags/UnleashFeatureFlagProvider.kt index 2772fec796..5adb25bd7a 100644 --- a/app/featureflags/feature-flags/src/androidMain/kotlin/com/hedvig/android/featureflags/flags/UnleashFeatureFlagProvider.kt +++ b/app/featureflags/feature-flags/src/androidMain/kotlin/com/hedvig/android/featureflags/flags/UnleashFeatureFlagProvider.kt @@ -24,7 +24,7 @@ internal class UnleashFeatureFlagProvider( .distinctUntilChanged() } - override suspend fun awaitFlagsFromServer() { - hedvigUnleashClient.awaitServerResponse() + override suspend fun awaitReady() { + hedvigUnleashClient.awaitReady() } } diff --git a/app/featureflags/feature-flags/src/commonMain/kotlin/com/hedvig/android/featureflags/FeatureManager.kt b/app/featureflags/feature-flags/src/commonMain/kotlin/com/hedvig/android/featureflags/FeatureManager.kt index efbd1701b6..a44136ca40 100644 --- a/app/featureflags/feature-flags/src/commonMain/kotlin/com/hedvig/android/featureflags/FeatureManager.kt +++ b/app/featureflags/feature-flags/src/commonMain/kotlin/com/hedvig/android/featureflags/FeatureManager.kt @@ -9,11 +9,11 @@ interface FeatureManager { fun isFeatureEnabled(feature: Feature): Flow /** - * Suspends until the flag values are confirmed authoritative for the current session: the backend has - * been reached and its current values are in effect, not a stale local fallback or a pre-fetch default. - * A decision that must honor the remote value, for example a kill switch gating a whole flow, should - * await this and treat a failure to complete as "value unknown". While the backend is unreachable - * (offline, or a backend outage) this never completes, so callers must impose their own timeout. + * Suspends until flag values are available for the current session, whether freshly fetched from the + * backend or restored from the last fetch's local cache. A decision that must honor the flag, for + * example a kill switch gating a whole flow, should await this and treat a failure to complete as "no + * value available yet". Until the app has ever reached the backend there is nothing to restore either, + * so this never completes; callers must impose their own timeout. */ - suspend fun awaitFlagsFromServer() + suspend fun awaitReady() } diff --git a/app/featureflags/feature-flags/src/iosMain/kotlin/com/hedvig/android/featureflags/di/IosFeatureManager.kt b/app/featureflags/feature-flags/src/iosMain/kotlin/com/hedvig/android/featureflags/di/IosFeatureManager.kt index db1d245029..abe6d5e9f3 100644 --- a/app/featureflags/feature-flags/src/iosMain/kotlin/com/hedvig/android/featureflags/di/IosFeatureManager.kt +++ b/app/featureflags/feature-flags/src/iosMain/kotlin/com/hedvig/android/featureflags/di/IosFeatureManager.kt @@ -12,5 +12,5 @@ class IosFeatureManager(private val isFeatureEnabledBlock: (Feature) -> Boolean) emit(withContext(Dispatchers.Main.immediate) { isFeatureEnabledBlock(feature) }) } - override suspend fun awaitFlagsFromServer() = Unit + override suspend fun awaitReady() = Unit } diff --git a/app/featureflags/feature-flags/src/jvmMain/kotlin/com/hedvig/android/featureflags/flags/NoopFeatureFlagProvider.kt b/app/featureflags/feature-flags/src/jvmMain/kotlin/com/hedvig/android/featureflags/flags/NoopFeatureFlagProvider.kt index 01e32c364e..c3c8c25ff9 100644 --- a/app/featureflags/feature-flags/src/jvmMain/kotlin/com/hedvig/android/featureflags/flags/NoopFeatureFlagProvider.kt +++ b/app/featureflags/feature-flags/src/jvmMain/kotlin/com/hedvig/android/featureflags/flags/NoopFeatureFlagProvider.kt @@ -9,5 +9,5 @@ internal class NoopFeatureFlagProvider : FeatureManager { return flowOf(false) } - override suspend fun awaitFlagsFromServer() = Unit + override suspend fun awaitReady() = Unit } From 950f1ad1c5e66509d174371059b7af0e71ac2dac Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 28 Jul 2026 14:16:03 +0200 Subject: [PATCH 049/119] Trim duplicative comments in the onboarding flag gate The onboarding gate comment restated what "a flag value is available" means, which is already the documented contract of awaitReady(), and the "not user visible" note appeared both there and on the timeout constant. The HedvigUnleashClient.awaitReady KDoc repeated the caller-must-time-out contract that the FeatureManager interface already carries. Focus each comment on what is local to it: the gate on failing closed without marking seen, the client KDoc on the isReady() mechanics, and leave the timeout justification (including user visibility) on the timeout constant. --- .../android/feature/onboarding/gate/OnboardingGate.kt | 8 +++----- .../hedvig/android/featureflags/HedvigUnleashClient.kt | 8 +++----- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/gate/OnboardingGate.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/gate/OnboardingGate.kt index 6b0b8aed37..6126212b73 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/gate/OnboardingGate.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/gate/OnboardingGate.kt @@ -50,11 +50,9 @@ internal class OnboardingGateImpl( private val completeOnboardingUseCase: CompleteOnboardingUseCase, ) : OnboardingGate { override suspend fun shouldShowOnboarding(): Boolean { - // Decide only once a flag value is available, whether freshly fetched or restored from the last - // fetch's on-disk backup. Until the app has ever reached Unleash there is no value (fresh install - // offline, or an outage before the first fetch); we then fail closed and show nothing without - // marking onboarding seen, so a later launch that gets a value decides properly. This runs after - // Home is already on screen, so the wait is not user visible. + // Fail closed when no flag value can be obtained: show nothing without marking onboarding seen, so a + // later launch that does get a value decides properly. awaitReady() never completes while the app + // has never reached the flag backend, so the timeout is what bounds that case. val flagsAvailable = withTimeoutOrNull(FLAG_READY_TIMEOUT) { featureManager.awaitReady() true diff --git a/app/featureflags/feature-flags/src/androidMain/kotlin/com/hedvig/android/featureflags/HedvigUnleashClient.kt b/app/featureflags/feature-flags/src/androidMain/kotlin/com/hedvig/android/featureflags/HedvigUnleashClient.kt index d2b5c5a32d..f9576dc0a7 100644 --- a/app/featureflags/feature-flags/src/androidMain/kotlin/com/hedvig/android/featureflags/HedvigUnleashClient.kt +++ b/app/featureflags/feature-flags/src/androidMain/kotlin/com/hedvig/android/featureflags/HedvigUnleashClient.kt @@ -62,11 +62,9 @@ class HedvigUnleashClient( } /** - * Suspends until the SDK has flag values available for the current context, whether restored from the - * on-disk backup of the last successful fetch or freshly fetched from the network. Returns immediately - * if that has already happened. When the app has never fetched and cannot reach Unleash (a fresh - * install offline, or an outage before any fetch) there is nothing to restore either, so this never - * completes; callers must wrap it in a timeout and treat the timeout as "no flag value available yet". + * Suspends until Unleash reports isReady(), which it sets on the first non-empty toggle set from + * either the on-disk backup or a network fetch, returning immediately if it already has. Never + * completes while the app has never fetched and cannot reach Unleash, so callers must impose a timeout. */ suspend fun awaitReady() { if (client.isReady()) return From 02606c72ce0173135a253e853f9a69ce7cce3fbd Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 28 Jul 2026 14:49:01 +0200 Subject: [PATCH 050/119] Add comment economy as a second axis in the comments guideline The comments guideline only policed the kind of story a comment tells (history, rejected alternatives, process state). It said nothing about redundancy or verbosity, so a comment that merely restates a signature or a contract already documented on a nearby or interface KDoc passed the rule while still being noise. Frame the section around two failure modes, wrong-kind-of-story and repeats-what-is-already-there, and add the economy rule: say only what the code and its neighbours do not, state a shared point once at the spot that owns it, and prefer no comment over a redundant or verbose one. --- CLAUDE.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 049954b8e4..a3b3bd8fdd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -482,7 +482,7 @@ Configuration in `.editorconfig`: ### Comments -Code comments and KDoc must describe the **current** code and stand on their own. Before writing one, apply the test: *would this make sense to someone reading the file cold, with no knowledge of the PR, the conversation, or what was decided against?* If not, it does not belong in the source. Do not reference: +Code comments and KDoc must describe the **current** code and stand on their own. A comment fails to earn its place in two ways: it tells the wrong kind of story, or it repeats what is already there. Before writing one, apply the test: *would this make sense to someone reading the file cold, with no knowledge of the PR, the conversation, or what was decided against?* If not, it does not belong in the source. Do not reference: - **History / migration:** "Replaces Nav2…", "used to live in…". - **Rejected alternatives:** "…not the iOS glass", "instead of the old Y". @@ -490,6 +490,8 @@ Code comments and KDoc must describe the **current** code and stand on their own When tempted to write "X instead of Y", drop the Y half and justify X on its own terms. If that leaves nothing, the code was self-explanatory, so delete the comment. Migration/history/process context belongs in the commit message. +**Say only what the code and its neighbours don't.** A comment must add information not already visible at its location. Do not restate a signature, a type, or a contract that a nearby or interface KDoc already documents; a paraphrase of something visible at the same spot is noise. Keep it to the non-obvious point (usually the *why*); if it grows into several lines re-describing mechanics that the code already shows, cut it back. If the same point already lives elsewhere (an interface's KDoc, a constant's own comment), state it once, at the spot that owns it, rather than repeating it at each use. Prefer no comment over a redundant or verbose one. + ## Working with GraphQL ### Apollo Schema From 565ee4841f261ff7156f102dcb94baf6fe39a30d Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 28 Jul 2026 16:59:36 +0200 Subject: [PATCH 051/119] Give onboarding step gaps a minimum height so items never touch when squished --- .../onboarding/ui/OnboardingStepScaffold.kt | 12 +++++++++ .../ui/bundle/OnboardingBundleDestination.kt | 5 ++-- .../OnboardingCoInsuredDestination.kt | 5 ++-- .../consent/OnboardingConsentDestination.kt | 26 ++++++++++++++++++- .../ui/invite/OnboardingInviteDestination.kt | 5 ++-- .../payment/OnboardingPaymentDestination.kt | 7 ++--- .../ui/petid/OnboardingPetIdDestination.kt | 5 ++-- .../ui/phone/OnboardingPhoneDestination.kt | 16 +++++++++++- .../ui/theme/OnboardingThemeDestination.kt | 5 ++-- .../welcome/OnboardingWelcomeDestination.kt | 19 +++++++------- 10 files changed, 81 insertions(+), 24 deletions(-) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt index f90ebdbc45..77e061290c 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.hedvig.android.design.system.hedvig.ButtonDefaults import com.hedvig.android.design.system.hedvig.HedvigButton @@ -150,6 +151,17 @@ private fun OnboardingProgressBar(progress: OnboardingProgress, modifier: Modifi } } +/** + * A vertical gap that is at least [minHeight] and grows to fill the available space. The fixed part + * survives when the viewport is short (landscape, small screens) so adjacent items never touch, + * while the weight part keeps the content nicely spread out when there is room. + */ +@Composable +internal fun ColumnScope.OnboardingFlexibleSpacer(minHeight: Dp = 24.dp) { + Spacer(Modifier.height(minHeight)) + Spacer(Modifier.weight(1f)) +} + @Composable internal fun OnboardingStepHeader(title: String, description: String, modifier: Modifier = Modifier) { Column(modifier.padding(horizontal = 16.dp)) { diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt index 9e7355eaf4..9d1707f828 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt @@ -36,6 +36,7 @@ import com.hedvig.android.feature.onboarding.data.OnboardingCrossSell import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId +import com.hedvig.android.feature.onboarding.ui.OnboardingFlexibleSpacer import com.hedvig.android.feature.onboarding.ui.OnboardingProgress import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons import com.hedvig.android.feature.onboarding.ui.OnboardingStepHeader @@ -149,7 +150,7 @@ internal fun OnboardingBundleDestination( title = stringResource(Res.string.ONBOARDING_CROSS_SELL_TITLE), description = stringResource(Res.string.ONBOARDING_CROSS_SELL_SUBTITLE), ) - Spacer(Modifier.weight(1f)) + OnboardingFlexibleSpacer() for ((index, crossSell) in state.crossSells.withIndex()) { if (index > 0) { Spacer(Modifier.height(8.dp)) @@ -160,7 +161,7 @@ internal fun OnboardingBundleDestination( openUrl = openUrl, ) } - Spacer(Modifier.weight(1f)) + OnboardingFlexibleSpacer() OnboardingStepButtons( primaryText = stringResource(Res.string.ONBOARDING_CONTINUE_TO_APP_BUTTON), onPrimaryClick = { viewModel.emit(OnboardingBundleEvent.ContinueToApp) }, diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt index 3040ba1425..42ad2bbb6a 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt @@ -30,6 +30,7 @@ import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId import com.hedvig.android.feature.onboarding.ui.OnboardingContractCard +import com.hedvig.android.feature.onboarding.ui.OnboardingFlexibleSpacer import com.hedvig.android.feature.onboarding.ui.OnboardingProgress import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons import com.hedvig.android.feature.onboarding.ui.OnboardingStepHeader @@ -189,7 +190,7 @@ internal fun OnboardingCoInsuredDestination(viewModel: OnboardingCoInsuredViewMo title = stringResource(Res.string.ONBOARDING_ADD_COINSURED_TITLE), description = stringResource(Res.string.ONBOARDING_MISSING_INFO_SUBTITLE), ) - Spacer(Modifier.weight(1f)) + OnboardingFlexibleSpacer() Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { for (row in content.rows) { OnboardingContractCard( @@ -201,7 +202,7 @@ internal fun OnboardingCoInsuredDestination(viewModel: OnboardingCoInsuredViewMo ) } } - Spacer(Modifier.weight(1f)) + OnboardingFlexibleSpacer() HedvigText( text = stringResource(Res.string.ONBOARDING_ADD_INFO_LATER_LABEL), style = HedvigTheme.typography.finePrint, diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingConsentDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingConsentDestination.kt index 4113e485bc..100b9d0e39 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingConsentDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingConsentDestination.kt @@ -1,6 +1,8 @@ package com.hedvig.android.feature.onboarding.ui.consent +import androidx.compose.foundation.Image import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets @@ -13,6 +15,7 @@ import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -23,6 +26,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -42,6 +46,7 @@ import com.hedvig.android.design.system.hedvig.icon.HedvigIcons import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId +import com.hedvig.android.feature.onboarding.ui.OnboardingFlexibleSpacer import com.hedvig.android.feature.onboarding.ui.OnboardingProgress import com.hedvig.android.feature.onboarding.ui.OnboardingStepHeader import com.hedvig.android.feature.onboarding.ui.OnboardingStepScaffold @@ -56,7 +61,9 @@ import hedvig.resources.ONBOARDING_ANALYTICS_DENY_BUTTON import hedvig.resources.ONBOARDING_ANALYTICS_SUBTITLE import hedvig.resources.ONBOARDING_ANALYTICS_TITLE import hedvig.resources.Res +import hedvig.resources.onboarding_verified_card import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource @Inject @@ -163,7 +170,24 @@ internal fun OnboardingConsentDestination( title = stringResource(Res.string.ONBOARDING_ANALYTICS_TITLE), description = stringResource(Res.string.ONBOARDING_ANALYTICS_SUBTITLE), ) - Spacer(Modifier.weight(1f)) + OnboardingFlexibleSpacer() + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) { + Spacer( + Modifier + .matchParentSize() + .padding(start = 8.dp, top = 16.dp, end = 20.dp, bottom = 12.dp) + .shadow(elevation = 6.dp, shape = RoundedCornerShape(24.dp)), + ) + Image( + painter = painterResource(Res.drawable.onboarding_verified_card), + contentDescription = null, + modifier = Modifier.size(120.dp), + ) + } + OnboardingFlexibleSpacer() Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt index b3e981dc5a..48365c3315 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt @@ -34,6 +34,7 @@ import com.hedvig.android.design.system.hedvig.Surface import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId +import com.hedvig.android.feature.onboarding.ui.OnboardingFlexibleSpacer import com.hedvig.android.feature.onboarding.ui.OnboardingProgress import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons import com.hedvig.android.feature.onboarding.ui.OnboardingStepHeader @@ -151,14 +152,14 @@ internal fun OnboardingInviteDestination(viewModel: OnboardingInviteViewModel, n title = stringResource(Res.string.ONBOARDING_INVITE_FRIEND_TITLE), description = stringResource(Res.string.ONBOARDING_INVITE_FRIEND_SUBTITLE, state.incentiveDisplay), ) - Spacer(Modifier.weight(1f)) + OnboardingFlexibleSpacer() ExampleReferralsCard( incentiveDisplay = state.incentiveDisplay, modifier = Modifier .align(Alignment.CenterHorizontally) .fillMaxWidth(0.72f), ) - Spacer(Modifier.weight(1f)) + OnboardingFlexibleSpacer() OnboardingStepButtons( primaryText = stringResource(Res.string.general_continue_button), onPrimaryClick = { viewModel.emit(OnboardingInviteEvent.Continue) }, diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt index 49bf8c9e5a..3358e46601 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt @@ -26,6 +26,7 @@ import com.hedvig.android.feature.onboarding.data.OnboardingPayinStatus import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId +import com.hedvig.android.feature.onboarding.ui.OnboardingFlexibleSpacer import com.hedvig.android.feature.onboarding.ui.OnboardingProgress import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons import com.hedvig.android.feature.onboarding.ui.OnboardingStepHeader @@ -165,7 +166,7 @@ internal fun OnboardingPaymentDestination(viewModel: OnboardingPaymentViewModel, title = stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_TITLE), description = stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_SUBTITLE), ) - Spacer(Modifier.weight(1f)) + OnboardingFlexibleSpacer() HedvigText( text = stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_FOOTNOTE), style = HedvigTheme.typography.finePrint, @@ -189,7 +190,7 @@ internal fun OnboardingPaymentDestination(viewModel: OnboardingPaymentViewModel, // "Din betalmetod hålller på att sättas upp. Det kan ta några dagar." to Lokalise description = "Your payment method is being set up. This can take a few days.", ) - Spacer(Modifier.weight(1f)) + OnboardingFlexibleSpacer() OnboardingStepButtons( primaryText = stringResource(Res.string.general_continue_button), onPrimaryClick = { viewModel.emit(OnboardingPaymentEvent.Continue) }, @@ -202,7 +203,7 @@ internal fun OnboardingPaymentDestination(viewModel: OnboardingPaymentViewModel, // TODO: Add "Your payment method is connected" / "Din betalmetod är kopplad" to Lokalise description = "Your payment method is connected", ) - Spacer(Modifier.weight(1f)) + OnboardingFlexibleSpacer() // TODO: Add "You can switch accounts later in settings" / // "Du kan byta konto senare i inställningar" to Lokalise HedvigText( diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdDestination.kt index e1b5040f85..c006780b03 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdDestination.kt @@ -30,6 +30,7 @@ import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId import com.hedvig.android.feature.onboarding.ui.OnboardingContractCard +import com.hedvig.android.feature.onboarding.ui.OnboardingFlexibleSpacer import com.hedvig.android.feature.onboarding.ui.OnboardingProgress import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons import com.hedvig.android.feature.onboarding.ui.OnboardingStepHeader @@ -189,7 +190,7 @@ internal fun OnboardingPetIdDestination( title = stringResource(Res.string.ONBOARDING_ADD_PET_CHIP_IDS_TITLE), description = stringResource(Res.string.ONBOARDING_MISSING_INFO_PET_SUBTITLE), ) - Spacer(Modifier.weight(1f)) + OnboardingFlexibleSpacer() Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { for (row in content.rows) { OnboardingContractCard( @@ -201,7 +202,7 @@ internal fun OnboardingPetIdDestination( ) } } - Spacer(Modifier.weight(1f)) + OnboardingFlexibleSpacer() HedvigText( text = stringResource(Res.string.ONBOARDING_ADD_INFO_LATER_LABEL), style = HedvigTheme.typography.finePrint, diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt index 4254b85d60..5f9eca33ac 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt @@ -1,9 +1,11 @@ package com.hedvig.android.feature.onboarding.ui.phone +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.runtime.Composable @@ -14,6 +16,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp @@ -28,6 +31,7 @@ import com.hedvig.android.feature.onboarding.data.OnboardingRepository import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId +import com.hedvig.android.feature.onboarding.ui.OnboardingFlexibleSpacer import com.hedvig.android.feature.onboarding.ui.OnboardingProgress import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons import com.hedvig.android.feature.onboarding.ui.OnboardingStepHeader @@ -42,8 +46,10 @@ import hedvig.resources.ONBOARDING_PHONE_SUBTITLE import hedvig.resources.ONBOARDING_PHONE_TITLE import hedvig.resources.Res import hedvig.resources.general_save_button +import hedvig.resources.onboarding_phone_keypad import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource @Inject @@ -193,7 +199,15 @@ internal fun OnboardingPhoneDestination(viewModel: OnboardingPhoneViewModel, nav title = stringResource(Res.string.ONBOARDING_PHONE_TITLE), description = stringResource(Res.string.ONBOARDING_PHONE_SUBTITLE), ) - Spacer(Modifier.weight(1f)) + OnboardingFlexibleSpacer() + Image( + painter = painterResource(Res.drawable.onboarding_phone_keypad), + contentDescription = null, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .size(170.dp), + ) + OnboardingFlexibleSpacer() HedvigTextField( state = phoneNumberState, labelText = stringResource(Res.string.ONBOARDING_PHONE_TITLE), diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt index 23cc2afa03..54a45a0db1 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt @@ -34,6 +34,7 @@ import com.hedvig.android.design.system.hedvig.icon.Settings import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId +import com.hedvig.android.feature.onboarding.ui.OnboardingFlexibleSpacer import com.hedvig.android.feature.onboarding.ui.OnboardingProgress import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons import com.hedvig.android.feature.onboarding.ui.OnboardingStepHeader @@ -165,7 +166,7 @@ internal fun OnboardingThemeDestination(viewModel: OnboardingThemeViewModel, nav title = stringResource(Res.string.SETTINGS_THEME_DIALOG_TITLE), description = stringResource(Res.string.ONBOARDING_THEME_SUBTITLE), ) - Spacer(Modifier.weight(1f)) + OnboardingFlexibleSpacer() RadioGroup( options = listOf( RadioOption( @@ -193,7 +194,7 @@ internal fun OnboardingThemeDestination(viewModel: OnboardingThemeViewModel, nav .fillMaxWidth() .padding(horizontal = 16.dp), ) - Spacer(Modifier.weight(1f)) + OnboardingFlexibleSpacer() HedvigText( text = stringResource(Res.string.ONBOARDING_CHANGE_SETTINGS_LATER_LABEL), style = HedvigTheme.typography.finePrint, diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt index 34ae3234f2..ff776e227f 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt @@ -1,7 +1,6 @@ package com.hedvig.android.feature.onboarding.ui.welcome -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -28,6 +27,7 @@ import com.hedvig.android.design.system.hedvig.HedvigText import com.hedvig.android.design.system.hedvig.HedvigTheme import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator +import com.hedvig.android.feature.onboarding.ui.OnboardingFlexibleSpacer import com.hedvig.android.feature.onboarding.ui.OnboardingProgress import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons import com.hedvig.android.feature.onboarding.ui.OnboardingStepScaffold @@ -40,7 +40,9 @@ import hedvig.resources.ONBOARDING_WELCOME_BUTTON import hedvig.resources.ONBOARDING_WELCOME_SUBTITLE import hedvig.resources.ONBOARDING_WELCOME_TITLE import hedvig.resources.Res +import hedvig.resources.onboarding_hedvig_symbol import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource @Inject @@ -124,18 +126,17 @@ internal fun OnboardingWelcomeDestination(viewModel: OnboardingWelcomeViewModel) } is OnboardingWelcomeUiState.Content -> { - Spacer(Modifier.weight(1f)) + OnboardingFlexibleSpacer() Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), ) { - // Static stand-in for the Figma app-icon-with-badge graphic; animation is a follow-up. - Box( - Modifier - .size(96.dp) - .background(HedvigTheme.colorScheme.fillPrimary, HedvigTheme.shapes.cornerLarge), + Image( + painter = painterResource(Res.drawable.onboarding_hedvig_symbol), + contentDescription = null, + modifier = Modifier.size(96.dp), ) Spacer(Modifier.height(24.dp)) HedvigText( @@ -148,7 +149,7 @@ internal fun OnboardingWelcomeDestination(viewModel: OnboardingWelcomeViewModel) textAlign = TextAlign.Center, ) } - Spacer(Modifier.weight(1f)) + OnboardingFlexibleSpacer() OnboardingStepButtons( primaryText = stringResource(Res.string.ONBOARDING_WELCOME_BUTTON), onPrimaryClick = { viewModel.emit(OnboardingWelcomeEvent.GetStarted) }, From b6a5d412e245073c4a33553cec6b6491bc8321da Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 28 Jul 2026 20:00:39 +0200 Subject: [PATCH 052/119] Inline the weight-plus-fixed spacer idiom to match house style --- .../feature/onboarding/ui/OnboardingStepScaffold.kt | 12 ------------ .../ui/bundle/OnboardingBundleDestination.kt | 7 ++++--- .../ui/coinsured/OnboardingCoInsuredDestination.kt | 7 ++++--- .../ui/consent/OnboardingConsentDestination.kt | 7 ++++--- .../ui/invite/OnboardingInviteDestination.kt | 7 ++++--- .../ui/payment/OnboardingPaymentDestination.kt | 10 ++++++---- .../ui/petid/OnboardingPetIdDestination.kt | 7 ++++--- .../ui/phone/OnboardingPhoneDestination.kt | 7 ++++--- .../ui/theme/OnboardingThemeDestination.kt | 7 ++++--- .../ui/welcome/OnboardingWelcomeDestination.kt | 7 ++++--- 10 files changed, 38 insertions(+), 40 deletions(-) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt index 77e061290c..f90ebdbc45 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt @@ -24,7 +24,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.hedvig.android.design.system.hedvig.ButtonDefaults import com.hedvig.android.design.system.hedvig.HedvigButton @@ -151,17 +150,6 @@ private fun OnboardingProgressBar(progress: OnboardingProgress, modifier: Modifi } } -/** - * A vertical gap that is at least [minHeight] and grows to fill the available space. The fixed part - * survives when the viewport is short (landscape, small screens) so adjacent items never touch, - * while the weight part keeps the content nicely spread out when there is room. - */ -@Composable -internal fun ColumnScope.OnboardingFlexibleSpacer(minHeight: Dp = 24.dp) { - Spacer(Modifier.height(minHeight)) - Spacer(Modifier.weight(1f)) -} - @Composable internal fun OnboardingStepHeader(title: String, description: String, modifier: Modifier = Modifier) { Column(modifier.padding(horizontal = 16.dp)) { diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt index 9d1707f828..6e12b26ae4 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt @@ -36,7 +36,6 @@ import com.hedvig.android.feature.onboarding.data.OnboardingCrossSell import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId -import com.hedvig.android.feature.onboarding.ui.OnboardingFlexibleSpacer import com.hedvig.android.feature.onboarding.ui.OnboardingProgress import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons import com.hedvig.android.feature.onboarding.ui.OnboardingStepHeader @@ -150,7 +149,8 @@ internal fun OnboardingBundleDestination( title = stringResource(Res.string.ONBOARDING_CROSS_SELL_TITLE), description = stringResource(Res.string.ONBOARDING_CROSS_SELL_SUBTITLE), ) - OnboardingFlexibleSpacer() + Spacer(Modifier.weight(1f)) + Spacer(Modifier.height(24.dp)) for ((index, crossSell) in state.crossSells.withIndex()) { if (index > 0) { Spacer(Modifier.height(8.dp)) @@ -161,7 +161,8 @@ internal fun OnboardingBundleDestination( openUrl = openUrl, ) } - OnboardingFlexibleSpacer() + Spacer(Modifier.weight(1f)) + Spacer(Modifier.height(24.dp)) OnboardingStepButtons( primaryText = stringResource(Res.string.ONBOARDING_CONTINUE_TO_APP_BUTTON), onPrimaryClick = { viewModel.emit(OnboardingBundleEvent.ContinueToApp) }, diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt index 42ad2bbb6a..9e1d9d6194 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt @@ -30,7 +30,6 @@ import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId import com.hedvig.android.feature.onboarding.ui.OnboardingContractCard -import com.hedvig.android.feature.onboarding.ui.OnboardingFlexibleSpacer import com.hedvig.android.feature.onboarding.ui.OnboardingProgress import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons import com.hedvig.android.feature.onboarding.ui.OnboardingStepHeader @@ -190,7 +189,8 @@ internal fun OnboardingCoInsuredDestination(viewModel: OnboardingCoInsuredViewMo title = stringResource(Res.string.ONBOARDING_ADD_COINSURED_TITLE), description = stringResource(Res.string.ONBOARDING_MISSING_INFO_SUBTITLE), ) - OnboardingFlexibleSpacer() + Spacer(Modifier.weight(1f)) + Spacer(Modifier.height(24.dp)) Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { for (row in content.rows) { OnboardingContractCard( @@ -202,7 +202,8 @@ internal fun OnboardingCoInsuredDestination(viewModel: OnboardingCoInsuredViewMo ) } } - OnboardingFlexibleSpacer() + Spacer(Modifier.weight(1f)) + Spacer(Modifier.height(24.dp)) HedvigText( text = stringResource(Res.string.ONBOARDING_ADD_INFO_LATER_LABEL), style = HedvigTheme.typography.finePrint, diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingConsentDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingConsentDestination.kt index 100b9d0e39..e01369066e 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingConsentDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingConsentDestination.kt @@ -46,7 +46,6 @@ import com.hedvig.android.design.system.hedvig.icon.HedvigIcons import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId -import com.hedvig.android.feature.onboarding.ui.OnboardingFlexibleSpacer import com.hedvig.android.feature.onboarding.ui.OnboardingProgress import com.hedvig.android.feature.onboarding.ui.OnboardingStepHeader import com.hedvig.android.feature.onboarding.ui.OnboardingStepScaffold @@ -170,7 +169,8 @@ internal fun OnboardingConsentDestination( title = stringResource(Res.string.ONBOARDING_ANALYTICS_TITLE), description = stringResource(Res.string.ONBOARDING_ANALYTICS_SUBTITLE), ) - OnboardingFlexibleSpacer() + Spacer(Modifier.weight(1f)) + Spacer(Modifier.height(24.dp)) Box( contentAlignment = Alignment.Center, modifier = Modifier.align(Alignment.CenterHorizontally), @@ -187,7 +187,8 @@ internal fun OnboardingConsentDestination( modifier = Modifier.size(120.dp), ) } - OnboardingFlexibleSpacer() + Spacer(Modifier.weight(1f)) + Spacer(Modifier.height(24.dp)) Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt index 48365c3315..cee8f87c0a 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt @@ -34,7 +34,6 @@ import com.hedvig.android.design.system.hedvig.Surface import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId -import com.hedvig.android.feature.onboarding.ui.OnboardingFlexibleSpacer import com.hedvig.android.feature.onboarding.ui.OnboardingProgress import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons import com.hedvig.android.feature.onboarding.ui.OnboardingStepHeader @@ -152,14 +151,16 @@ internal fun OnboardingInviteDestination(viewModel: OnboardingInviteViewModel, n title = stringResource(Res.string.ONBOARDING_INVITE_FRIEND_TITLE), description = stringResource(Res.string.ONBOARDING_INVITE_FRIEND_SUBTITLE, state.incentiveDisplay), ) - OnboardingFlexibleSpacer() + Spacer(Modifier.weight(1f)) + Spacer(Modifier.height(24.dp)) ExampleReferralsCard( incentiveDisplay = state.incentiveDisplay, modifier = Modifier .align(Alignment.CenterHorizontally) .fillMaxWidth(0.72f), ) - OnboardingFlexibleSpacer() + Spacer(Modifier.weight(1f)) + Spacer(Modifier.height(24.dp)) OnboardingStepButtons( primaryText = stringResource(Res.string.general_continue_button), onPrimaryClick = { viewModel.emit(OnboardingInviteEvent.Continue) }, diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt index 3358e46601..09fad76730 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt @@ -26,7 +26,6 @@ import com.hedvig.android.feature.onboarding.data.OnboardingPayinStatus import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId -import com.hedvig.android.feature.onboarding.ui.OnboardingFlexibleSpacer import com.hedvig.android.feature.onboarding.ui.OnboardingProgress import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons import com.hedvig.android.feature.onboarding.ui.OnboardingStepHeader @@ -166,7 +165,8 @@ internal fun OnboardingPaymentDestination(viewModel: OnboardingPaymentViewModel, title = stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_TITLE), description = stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_SUBTITLE), ) - OnboardingFlexibleSpacer() + Spacer(Modifier.weight(1f)) + Spacer(Modifier.height(24.dp)) HedvigText( text = stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_FOOTNOTE), style = HedvigTheme.typography.finePrint, @@ -190,7 +190,8 @@ internal fun OnboardingPaymentDestination(viewModel: OnboardingPaymentViewModel, // "Din betalmetod hålller på att sättas upp. Det kan ta några dagar." to Lokalise description = "Your payment method is being set up. This can take a few days.", ) - OnboardingFlexibleSpacer() + Spacer(Modifier.weight(1f)) + Spacer(Modifier.height(24.dp)) OnboardingStepButtons( primaryText = stringResource(Res.string.general_continue_button), onPrimaryClick = { viewModel.emit(OnboardingPaymentEvent.Continue) }, @@ -203,7 +204,8 @@ internal fun OnboardingPaymentDestination(viewModel: OnboardingPaymentViewModel, // TODO: Add "Your payment method is connected" / "Din betalmetod är kopplad" to Lokalise description = "Your payment method is connected", ) - OnboardingFlexibleSpacer() + Spacer(Modifier.weight(1f)) + Spacer(Modifier.height(24.dp)) // TODO: Add "You can switch accounts later in settings" / // "Du kan byta konto senare i inställningar" to Lokalise HedvigText( diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdDestination.kt index c006780b03..a795c8ac53 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdDestination.kt @@ -30,7 +30,6 @@ import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId import com.hedvig.android.feature.onboarding.ui.OnboardingContractCard -import com.hedvig.android.feature.onboarding.ui.OnboardingFlexibleSpacer import com.hedvig.android.feature.onboarding.ui.OnboardingProgress import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons import com.hedvig.android.feature.onboarding.ui.OnboardingStepHeader @@ -190,7 +189,8 @@ internal fun OnboardingPetIdDestination( title = stringResource(Res.string.ONBOARDING_ADD_PET_CHIP_IDS_TITLE), description = stringResource(Res.string.ONBOARDING_MISSING_INFO_PET_SUBTITLE), ) - OnboardingFlexibleSpacer() + Spacer(Modifier.weight(1f)) + Spacer(Modifier.height(24.dp)) Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { for (row in content.rows) { OnboardingContractCard( @@ -202,7 +202,8 @@ internal fun OnboardingPetIdDestination( ) } } - OnboardingFlexibleSpacer() + Spacer(Modifier.weight(1f)) + Spacer(Modifier.height(24.dp)) HedvigText( text = stringResource(Res.string.ONBOARDING_ADD_INFO_LATER_LABEL), style = HedvigTheme.typography.finePrint, diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt index 5f9eca33ac..1b5051bdea 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt @@ -31,7 +31,6 @@ import com.hedvig.android.feature.onboarding.data.OnboardingRepository import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId -import com.hedvig.android.feature.onboarding.ui.OnboardingFlexibleSpacer import com.hedvig.android.feature.onboarding.ui.OnboardingProgress import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons import com.hedvig.android.feature.onboarding.ui.OnboardingStepHeader @@ -199,7 +198,8 @@ internal fun OnboardingPhoneDestination(viewModel: OnboardingPhoneViewModel, nav title = stringResource(Res.string.ONBOARDING_PHONE_TITLE), description = stringResource(Res.string.ONBOARDING_PHONE_SUBTITLE), ) - OnboardingFlexibleSpacer() + Spacer(Modifier.weight(1f)) + Spacer(Modifier.height(24.dp)) Image( painter = painterResource(Res.drawable.onboarding_phone_keypad), contentDescription = null, @@ -207,7 +207,8 @@ internal fun OnboardingPhoneDestination(viewModel: OnboardingPhoneViewModel, nav .align(Alignment.CenterHorizontally) .size(170.dp), ) - OnboardingFlexibleSpacer() + Spacer(Modifier.weight(1f)) + Spacer(Modifier.height(24.dp)) HedvigTextField( state = phoneNumberState, labelText = stringResource(Res.string.ONBOARDING_PHONE_TITLE), diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt index 54a45a0db1..c562840818 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt @@ -34,7 +34,6 @@ import com.hedvig.android.design.system.hedvig.icon.Settings import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId -import com.hedvig.android.feature.onboarding.ui.OnboardingFlexibleSpacer import com.hedvig.android.feature.onboarding.ui.OnboardingProgress import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons import com.hedvig.android.feature.onboarding.ui.OnboardingStepHeader @@ -166,7 +165,8 @@ internal fun OnboardingThemeDestination(viewModel: OnboardingThemeViewModel, nav title = stringResource(Res.string.SETTINGS_THEME_DIALOG_TITLE), description = stringResource(Res.string.ONBOARDING_THEME_SUBTITLE), ) - OnboardingFlexibleSpacer() + Spacer(Modifier.weight(1f)) + Spacer(Modifier.height(24.dp)) RadioGroup( options = listOf( RadioOption( @@ -194,7 +194,8 @@ internal fun OnboardingThemeDestination(viewModel: OnboardingThemeViewModel, nav .fillMaxWidth() .padding(horizontal = 16.dp), ) - OnboardingFlexibleSpacer() + Spacer(Modifier.weight(1f)) + Spacer(Modifier.height(24.dp)) HedvigText( text = stringResource(Res.string.ONBOARDING_CHANGE_SETTINGS_LATER_LABEL), style = HedvigTheme.typography.finePrint, diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt index ff776e227f..14d80b591a 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt @@ -27,7 +27,6 @@ import com.hedvig.android.design.system.hedvig.HedvigText import com.hedvig.android.design.system.hedvig.HedvigTheme import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator -import com.hedvig.android.feature.onboarding.ui.OnboardingFlexibleSpacer import com.hedvig.android.feature.onboarding.ui.OnboardingProgress import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons import com.hedvig.android.feature.onboarding.ui.OnboardingStepScaffold @@ -126,7 +125,8 @@ internal fun OnboardingWelcomeDestination(viewModel: OnboardingWelcomeViewModel) } is OnboardingWelcomeUiState.Content -> { - OnboardingFlexibleSpacer() + Spacer(Modifier.weight(1f)) + Spacer(Modifier.height(24.dp)) Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier @@ -149,7 +149,8 @@ internal fun OnboardingWelcomeDestination(viewModel: OnboardingWelcomeViewModel) textAlign = TextAlign.Center, ) } - OnboardingFlexibleSpacer() + Spacer(Modifier.weight(1f)) + Spacer(Modifier.height(24.dp)) OnboardingStepButtons( primaryText = stringResource(Res.string.ONBOARDING_WELCOME_BUTTON), onPrimaryClick = { viewModel.emit(OnboardingWelcomeEvent.GetStarted) }, From eae3936bbda3c009f703785b5ec1e453f31300fe Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 28 Jul 2026 21:06:14 +0200 Subject: [PATCH 053/119] Add onboarding welcome, phone, and analytics graphics to core-resources Three onboarding illustrations imported from Figma into the shared core-resources compose-resources drawables: - onboarding_hedvig_symbol (WebP): welcome screen app symbol - onboarding_phone_keypad (vector): phone-number step keypad - onboarding_verified_card (vector): analytics-consent step --- .../drawable/onboarding_hedvig_symbol.webp | Bin 0 -> 59842 bytes .../drawable/onboarding_phone_keypad.xml | 90 ++++++++++++++++++ .../drawable/onboarding_verified_card.xml | 21 ++++ 3 files changed, 111 insertions(+) create mode 100644 app/core/core-resources/src/commonMain/composeResources/drawable/onboarding_hedvig_symbol.webp create mode 100644 app/core/core-resources/src/commonMain/composeResources/drawable/onboarding_phone_keypad.xml create mode 100644 app/core/core-resources/src/commonMain/composeResources/drawable/onboarding_verified_card.xml diff --git a/app/core/core-resources/src/commonMain/composeResources/drawable/onboarding_hedvig_symbol.webp b/app/core/core-resources/src/commonMain/composeResources/drawable/onboarding_hedvig_symbol.webp new file mode 100644 index 0000000000000000000000000000000000000000..dccdfa8c2426fb3fb1d883d6cd0c80e7090ac5c4 GIT binary patch literal 59842 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 z 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 Par?DG5CB!<-~a#sLO7y+ literal 0 HcmV?d00001 diff --git a/app/core/core-resources/src/commonMain/composeResources/drawable/onboarding_phone_keypad.xml b/app/core/core-resources/src/commonMain/composeResources/drawable/onboarding_phone_keypad.xml new file mode 100644 index 0000000000..2cb4cd4a0c --- /dev/null +++ b/app/core/core-resources/src/commonMain/composeResources/drawable/onboarding_phone_keypad.xml @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/core/core-resources/src/commonMain/composeResources/drawable/onboarding_verified_card.xml b/app/core/core-resources/src/commonMain/composeResources/drawable/onboarding_verified_card.xml new file mode 100644 index 0000000000..5b0fb564c4 --- /dev/null +++ b/app/core/core-resources/src/commonMain/composeResources/drawable/onboarding_verified_card.xml @@ -0,0 +1,21 @@ + + + + + + From e35c992138cae0c492488d29fa6c106121217c13 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 28 Jul 2026 21:41:02 +0200 Subject: [PATCH 054/119] Replace onboarding phone keypad image with an animated Compose component The phone-step keypad is now a bespoke Compose component (a 4x3 grid of squircle keys with centred glyphs) instead of a static vector, so one key can animate its size and background colour on screen entry. - Glyphs ignore the system font-scale (decorative), stay optically centred, and grow via a layer scale so the grid never reflows. - Hidden from the accessibility tree; it is not read out key by key. - Removes the now-unused onboarding_phone_keypad vector drawable. --- .../drawable/onboarding_phone_keypad.xml | 90 ----------- .../ui/phone/OnboardingPhoneDestination.kt | 12 +- .../ui/phone/OnboardingPhoneKeypad.kt | 153 ++++++++++++++++++ 3 files changed, 154 insertions(+), 101 deletions(-) delete mode 100644 app/core/core-resources/src/commonMain/composeResources/drawable/onboarding_phone_keypad.xml create mode 100644 app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneKeypad.kt diff --git a/app/core/core-resources/src/commonMain/composeResources/drawable/onboarding_phone_keypad.xml b/app/core/core-resources/src/commonMain/composeResources/drawable/onboarding_phone_keypad.xml deleted file mode 100644 index 2cb4cd4a0c..0000000000 --- a/app/core/core-resources/src/commonMain/composeResources/drawable/onboarding_phone_keypad.xml +++ /dev/null @@ -1,90 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt index 1b5051bdea..0ff849bc42 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt @@ -1,11 +1,9 @@ package com.hedvig.android.feature.onboarding.ui.phone -import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.runtime.Composable @@ -45,10 +43,8 @@ import hedvig.resources.ONBOARDING_PHONE_SUBTITLE import hedvig.resources.ONBOARDING_PHONE_TITLE import hedvig.resources.Res import hedvig.resources.general_save_button -import hedvig.resources.onboarding_phone_keypad import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch -import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource @Inject @@ -200,13 +196,7 @@ internal fun OnboardingPhoneDestination(viewModel: OnboardingPhoneViewModel, nav ) Spacer(Modifier.weight(1f)) Spacer(Modifier.height(24.dp)) - Image( - painter = painterResource(Res.drawable.onboarding_phone_keypad), - contentDescription = null, - modifier = Modifier - .align(Alignment.CenterHorizontally) - .size(170.dp), - ) + OnboardingPhoneKeypad(modifier = Modifier.align(Alignment.CenterHorizontally)) Spacer(Modifier.weight(1f)) Spacer(Modifier.height(24.dp)) HedvigTextField( diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneKeypad.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneKeypad.kt new file mode 100644 index 0000000000..72022b6f6a --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneKeypad.kt @@ -0,0 +1,153 @@ +package com.hedvig.android.feature.onboarding.ui.phone + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.text.PlatformTextStyle +import androidx.compose.ui.text.style.LineHeightStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.hedvig.android.design.system.hedvig.HedvigPreview +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.LocalTextStyle +import com.hedvig.android.design.system.hedvig.Surface +import com.hedvig.android.design.system.hedvig.tokens.MotionTokens + +private val KeySize = 36.dp +private val HorizontalGap = 11.dp +private val VerticalGap = 9.dp +private val GlyphFontSize = 20.sp +private const val HighlightScale = 1.12f + +private val KeypadRows = listOf( + listOf("1", "2", "3"), + listOf("4", "5", "6"), + listOf("7", "8", "9"), + listOf("*", "0", "#"), +) + +/** + * The keypad illustration on the phone-number step. One key is highlighted with a size + colour + * animation once the screen is shown; [highlightedKey] selects which. + */ +@Composable +internal fun OnboardingPhoneKeypad(modifier: Modifier = Modifier, highlightedKey: String = "5") { + var entered by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { entered = true } + KeypadGrid(highlightedKey = highlightedKey, highlightActive = entered, modifier = modifier) +} + +@Composable +private fun KeypadGrid(highlightedKey: String, highlightActive: Boolean, modifier: Modifier = Modifier) { + // Decorative artwork: freeze the font scale so the glyphs keep their designed size regardless of + // the user's system font-size setting. + val density = LocalDensity.current + CompositionLocalProvider(LocalDensity provides Density(density.density, fontScale = 1f)) { + Column( + // Purely decorative: keep it out of the accessibility tree so it is not read out key by key. + modifier = modifier.clearAndSetSemantics {}, + verticalArrangement = Arrangement.spacedBy(VerticalGap), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + for (row in KeypadRows) { + Row(horizontalArrangement = Arrangement.spacedBy(HorizontalGap)) { + for (label in row) { + KeypadKey(label = label, highlighted = highlightActive && label == highlightedKey) + } + } + } + } + } +} + +@Composable +private fun KeypadKey(label: String, highlighted: Boolean) { + val containerColor by animateColorAsState( + targetValue = if (highlighted) { + HedvigTheme.colorScheme.signalGreenFill + } else { + HedvigTheme.colorScheme.textPrimary.copy(alpha = 0.068f) + }, + animationSpec = tween(MotionTokens.DurationLong1.toInt(), easing = MotionTokens.EasingEmphasizedCubicBezier), + label = "keypad container colour", + ) + val contentColor by animateColorAsState( + targetValue = if (highlighted) HedvigTheme.colorScheme.signalGreenText else HedvigTheme.colorScheme.textPrimary, + animationSpec = tween(MotionTokens.DurationLong1.toInt(), easing = MotionTokens.EasingEmphasizedCubicBezier), + label = "keypad content colour", + ) + val scale by animateFloatAsState( + targetValue = if (highlighted) HighlightScale else 1f, + animationSpec = tween(MotionTokens.DurationLong1.toInt(), easing = MotionTokens.EasingEmphasizedCubicBezier), + label = "keypad scale", + ) + Box( + // Grow via a layer transform rather than the layout size so the row never reflows and the glyph + // stays centred throughout the animation. + modifier = Modifier + .size(KeySize) + .graphicsLayer { + scaleX = scale + scaleY = scale + } + .background(containerColor, HedvigTheme.shapes.cornerXLarge), + contentAlignment = Alignment.Center, + ) { + HedvigText( + text = label, + color = contentColor, + fontSize = GlyphFontSize, + lineHeight = GlyphFontSize, + textAlign = TextAlign.Center, + style = LocalTextStyle.current.copy( + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.Both, + ), + platformStyle = PlatformTextStyle(includeFontPadding = false), + ), + ) + } +} + +@HedvigPreview +@Composable +private fun PreviewOnboardingPhoneKeypadHighlighted() { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + KeypadGrid(highlightedKey = "5", highlightActive = true, modifier = Modifier.padding(24.dp)) + } + } +} + +@HedvigPreview +@Composable +private fun PreviewOnboardingPhoneKeypadResting() { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + KeypadGrid(highlightedKey = "5", highlightActive = false, modifier = Modifier.padding(24.dp)) + } + } +} From 1bd9731bf08525066731c4f7ea0de0748f85ad21 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 28 Jul 2026 22:22:46 +0200 Subject: [PATCH 055/119] Add spec for onboarding persistent top bar shared element --- ...28-onboarding-persistent-top-bar-design.md | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-28-onboarding-persistent-top-bar-design.md diff --git a/docs/superpowers/specs/2026-07-28-onboarding-persistent-top-bar-design.md b/docs/superpowers/specs/2026-07-28-onboarding-persistent-top-bar-design.md new file mode 100644 index 0000000000..5ef43c9a97 --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-onboarding-persistent-top-bar-design.md @@ -0,0 +1,86 @@ +# Onboarding top bar as a persistent shared element + +## Problem + +In the onboarding flow every step is its own Nav3 entry (`OnboardingKey` for +welcome, `OnboardingStepKey` for each subsequent step). Each entry independently +renders its own top header through `OnboardingStepScaffold`: a 56dp `Row` holding +the back button, the progress-steps bar, and the close button. + +Because the header is part of the entry, it animates with the rest of the screen +during navigation. The default onboarding transition is `sharedXAxis` (slide + +fade), so the header slides and fades along with the page content. We want the +header, and specifically the progress-steps component, to stay anchored at the +top so the member perceives one continuous progressive flow rather than a fresh +screen each step. + +## Goal + +During transitions between onboarding entries that share the scaffold, the entire +56dp header (back button + progress steps + close) stays visually pinned at the +top while only the page content below it slides/fades. Content within the header +that legitimately differs per step (the progress fill advancing, the back button +appearing after welcome) cross-fades in place. + +## Mechanism + +Nav3 already runs the whole app inside a single `SharedTransitionLayout` in +`HedvigApp`, exposed app-wide via `LocalSharedTransitionScope` (from +`:compose-ui`). It also provides `LocalNavAnimatedContentScope` (from +`navigation3-ui`) inside every `NavEntry`. + +A shared element declared with a stable key in **both** the outgoing and incoming +entry is lifted into the transition overlay, and its bounds are animated from the +source position to the target position. Because the header occupies the identical +position and size in every onboarding entry, that bounds animation is a visual +no-op: the bar looks pinned while everything below runs the normal `sharedXAxis` +transition. + +We use `sharedBounds` (not `sharedElement`) because the header's content is +legitimately different between steps; `sharedBounds` cross-fades the differing +content while animating the shared bounds. + +## Scope of the change + +One production file: `OnboardingStepScaffold.kt`. + +- Wrap the header `Row` in + `Modifier.sharedBounds(rememberSharedContentState(key), animatedVisibilityScope = LocalNavAnimatedContentScope.current)` + using the app-wide `LocalSharedTransitionScope`. +- The key is a single module-level constant so every onboarding entry matches the + same element. + +Every onboarding destination already routes its header through this one scaffold, +so no per-destination edits are needed. + +### Preview safety + +The scaffold's `@HedvigPreview`s call it outside any `SharedTransitionLayout` or +`NavEntry`. `LocalSharedTransitionScope.current` is nullable and safe to read +(null in previews), but `LocalNavAnimatedContentScope.current` throws when read +outside a NavEntry. + +Guard: read the nullable shared-transition scope first; only when it is non-null +(i.e. we are in the live app) do we touch `LocalNavAnimatedContentScope` and apply +`sharedBounds`. Otherwise the header renders plainly. Previews keep working. + +### Module dependencies + +`feature-onboarding` reaches neither local transitively today +(`navigation-compose` pulls `navigation3-ui` as `implementation`; `:compose-ui` +is not exposed). Add two: + +- `implementation(projects.composeUi)` for `LocalSharedTransitionScope` +- `implementation(libs.androidx.navigation3.ui)` for `LocalNavAnimatedContentScope` + +## Explicitly out of scope + +- `OnboardingForeverKey` renders `ForeverDestination`, not this scaffold, so it has + no header to match. Navigating into/out of it (and into the external Trustly + connect-payment flow) has no shared element and transitions normally. This is + the intended behavior: only in-flow steps sharing the scaffold carry the pinned + bar. +- No progress-fill animation beyond the default `sharedBounds` cross-fade. +- No new tests. This is a visual transition with no logic branch; the existing + (now guarded) scaffold previews plus a manual run-through of the flow are the + verification surface. From ce6cbb581649dce30df3110adbd22d3f9d12b685 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 28 Jul 2026 22:34:11 +0200 Subject: [PATCH 056/119] Pin onboarding top bar as a shared element across steps --- .../feature-onboarding/build.gradle.kts | 2 ++ .../onboarding/ui/OnboardingStepScaffold.kt | 27 ++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/app/feature/feature-onboarding/build.gradle.kts b/app/feature/feature-onboarding/build.gradle.kts index 83e55b2f02..dd38876cd6 100644 --- a/app/feature/feature-onboarding/build.gradle.kts +++ b/app/feature/feature-onboarding/build.gradle.kts @@ -23,11 +23,13 @@ dependencies { implementation(libs.coroutines.core) implementation(libs.androidx.datastore.core) implementation(libs.androidx.datastore.preferencesCore) + implementation(libs.androidx.navigation3.ui) implementation(libs.jetbrains.lifecycle.runtime.compose) implementation(libs.kotlinx.serialization.core) implementation(projects.apolloCore) implementation(projects.apolloOctopusPublic) implementation(projects.authCorePublic) + implementation(projects.composeUi) implementation(projects.coreCommonPublic) implementation(projects.coreResources) implementation(libs.coil.coil) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt index f90ebdbc45..d23a5d2b75 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt @@ -1,5 +1,6 @@ package com.hedvig.android.feature.onboarding.ui +import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -25,6 +26,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp +import androidx.navigation3.ui.LocalNavAnimatedContentScope +import com.hedvig.android.compose.ui.LocalSharedTransitionScope import com.hedvig.android.design.system.hedvig.ButtonDefaults import com.hedvig.android.design.system.hedvig.HedvigButton import com.hedvig.android.design.system.hedvig.HedvigPreview @@ -60,6 +63,14 @@ internal fun OnboardingSession.progressFor(stepId: OnboardingStepId?): Onboardin ) } +/** + * Ties every in-flow step's header to the same shared element so it stays pinned while the page + * content below runs the normal transition. Steps hosted by different destinations (Forever, the + * external connect-payment flow) don't render this scaffold, so they never match it. + */ +private const val OnboardingTopBarSharedKey = "onboarding-top-bar" + +@OptIn(ExperimentalSharedTransitionApi::class) @Composable internal fun OnboardingStepScaffold( progress: OnboardingProgress?, @@ -69,6 +80,20 @@ internal fun OnboardingStepScaffold( modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit, ) { + // Null only in isolated previews, where there is no NavEntry to read the animated-content scope + // from; the header then renders plainly. + val sharedTransitionScope = LocalSharedTransitionScope.current + val headerModifier = if (sharedTransitionScope != null) { + val animatedContentScope = LocalNavAnimatedContentScope.current + with(sharedTransitionScope) { + Modifier.sharedBounds( + sharedContentState = rememberSharedContentState(OnboardingTopBarSharedKey), + animatedVisibilityScope = animatedContentScope, + ) + } + } else { + Modifier + } Surface( color = HedvigTheme.colorScheme.backgroundPrimary, modifier = modifier.fillMaxSize(), @@ -80,7 +105,7 @@ internal fun OnboardingStepScaffold( ) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier + modifier = headerModifier .fillMaxWidth() .height(56.dp) .padding(horizontal = 16.dp), From 97c70babe63aa235ad03ddd029144035189c026d Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 29 Jul 2026 12:00:29 +0200 Subject: [PATCH 057/119] Animate the "7" button as a fixed decision --- .../ui/phone/OnboardingPhoneKeypad.kt | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneKeypad.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneKeypad.kt index 72022b6f6a..2855ab227b 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneKeypad.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneKeypad.kt @@ -34,6 +34,8 @@ import com.hedvig.android.design.system.hedvig.HedvigTheme import com.hedvig.android.design.system.hedvig.LocalTextStyle import com.hedvig.android.design.system.hedvig.Surface import com.hedvig.android.design.system.hedvig.tokens.MotionTokens +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.delay private val KeySize = 36.dp private val HorizontalGap = 11.dp @@ -48,19 +50,18 @@ private val KeypadRows = listOf( listOf("*", "0", "#"), ) -/** - * The keypad illustration on the phone-number step. One key is highlighted with a size + colour - * animation once the screen is shown; [highlightedKey] selects which. - */ @Composable -internal fun OnboardingPhoneKeypad(modifier: Modifier = Modifier, highlightedKey: String = "5") { +internal fun OnboardingPhoneKeypad(modifier: Modifier = Modifier) { var entered by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { entered = true } - KeypadGrid(highlightedKey = highlightedKey, highlightActive = entered, modifier = modifier) + LaunchedEffect(Unit) { + delay(2.seconds) + entered = true + } + KeypadGrid(highlightActive = entered, modifier = modifier) } @Composable -private fun KeypadGrid(highlightedKey: String, highlightActive: Boolean, modifier: Modifier = Modifier) { +private fun KeypadGrid(highlightActive: Boolean, modifier: Modifier = Modifier) { // Decorative artwork: freeze the font scale so the glyphs keep their designed size regardless of // the user's system font-size setting. val density = LocalDensity.current @@ -74,7 +75,7 @@ private fun KeypadGrid(highlightedKey: String, highlightActive: Boolean, modifie for (row in KeypadRows) { Row(horizontalArrangement = Arrangement.spacedBy(HorizontalGap)) { for (label in row) { - KeypadKey(label = label, highlighted = highlightActive && label == highlightedKey) + KeypadKey(label = label, highlighted = highlightActive && label == KeypadRows[2][0]) } } } @@ -104,8 +105,6 @@ private fun KeypadKey(label: String, highlighted: Boolean) { label = "keypad scale", ) Box( - // Grow via a layer transform rather than the layout size so the row never reflows and the glyph - // stays centred throughout the animation. modifier = Modifier .size(KeySize) .graphicsLayer { @@ -137,7 +136,7 @@ private fun KeypadKey(label: String, highlighted: Boolean) { private fun PreviewOnboardingPhoneKeypadHighlighted() { HedvigTheme { Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { - KeypadGrid(highlightedKey = "5", highlightActive = true, modifier = Modifier.padding(24.dp)) + KeypadGrid(highlightActive = true, modifier = Modifier.padding(24.dp)) } } } @@ -147,7 +146,7 @@ private fun PreviewOnboardingPhoneKeypadHighlighted() { private fun PreviewOnboardingPhoneKeypadResting() { HedvigTheme { Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { - KeypadGrid(highlightedKey = "5", highlightActive = false, modifier = Modifier.padding(24.dp)) + KeypadGrid(highlightActive = false, modifier = Modifier.padding(24.dp)) } } } From bf011e0f551dc1a74c2cd3e4d393cd166aca0dd3 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 29 Jul 2026 12:45:46 +0200 Subject: [PATCH 058/119] Animate the analytics-consent verified badge in after a delay The green verified badge is split out of the card vector into its own overlay so it can be absent on the first frame and pop in a couple of seconds after the screen is shown. - onboarding_verified_card: card + bar chart only - onboarding_verified_badge: the green circle + white check, in the same 120-unit space so it overlays the card exactly - OnboardingVerifiedCard: card + shadow + badge, badge scales/fades in from its own centre after a 2s delay; hidden from the accessibility tree --- .../drawable/onboarding_verified_badge.xml | 12 ++ .../drawable/onboarding_verified_card.xml | 6 - .../consent/OnboardingConsentDestination.kt | 23 +--- .../ui/consent/OnboardingVerifiedCard.kt | 119 ++++++++++++++++++ 4 files changed, 132 insertions(+), 28 deletions(-) create mode 100644 app/core/core-resources/src/commonMain/composeResources/drawable/onboarding_verified_badge.xml create mode 100644 app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingVerifiedCard.kt diff --git a/app/core/core-resources/src/commonMain/composeResources/drawable/onboarding_verified_badge.xml b/app/core/core-resources/src/commonMain/composeResources/drawable/onboarding_verified_badge.xml new file mode 100644 index 0000000000..b1beaa629d --- /dev/null +++ b/app/core/core-resources/src/commonMain/composeResources/drawable/onboarding_verified_badge.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/core/core-resources/src/commonMain/composeResources/drawable/onboarding_verified_card.xml b/app/core/core-resources/src/commonMain/composeResources/drawable/onboarding_verified_card.xml index 5b0fb564c4..7187513927 100644 --- a/app/core/core-resources/src/commonMain/composeResources/drawable/onboarding_verified_card.xml +++ b/app/core/core-resources/src/commonMain/composeResources/drawable/onboarding_verified_card.xml @@ -12,10 +12,4 @@ - - diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingConsentDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingConsentDestination.kt index e01369066e..79500af6a6 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingConsentDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingConsentDestination.kt @@ -1,8 +1,6 @@ package com.hedvig.android.feature.onboarding.ui.consent -import androidx.compose.foundation.Image import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets @@ -15,7 +13,6 @@ import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -26,7 +23,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -60,9 +56,7 @@ import hedvig.resources.ONBOARDING_ANALYTICS_DENY_BUTTON import hedvig.resources.ONBOARDING_ANALYTICS_SUBTITLE import hedvig.resources.ONBOARDING_ANALYTICS_TITLE import hedvig.resources.Res -import hedvig.resources.onboarding_verified_card import kotlinx.coroutines.launch -import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource @Inject @@ -171,22 +165,7 @@ internal fun OnboardingConsentDestination( ) Spacer(Modifier.weight(1f)) Spacer(Modifier.height(24.dp)) - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.align(Alignment.CenterHorizontally), - ) { - Spacer( - Modifier - .matchParentSize() - .padding(start = 8.dp, top = 16.dp, end = 20.dp, bottom = 12.dp) - .shadow(elevation = 6.dp, shape = RoundedCornerShape(24.dp)), - ) - Image( - painter = painterResource(Res.drawable.onboarding_verified_card), - contentDescription = null, - modifier = Modifier.size(120.dp), - ) - } + OnboardingVerifiedCard(modifier = Modifier.align(Alignment.CenterHorizontally)) Spacer(Modifier.weight(1f)) Spacer(Modifier.height(24.dp)) Row( diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingVerifiedCard.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingVerifiedCard.kt new file mode 100644 index 0000000000..a2c768a6e1 --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingVerifiedCard.kt @@ -0,0 +1,119 @@ +package com.hedvig.android.feature.onboarding.ui.consent + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.unit.dp +import com.hedvig.android.design.system.hedvig.HedvigPreview +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.Surface +import com.hedvig.android.design.system.hedvig.tokens.MotionTokens +import hedvig.resources.Res +import hedvig.resources.onboarding_verified_badge +import hedvig.resources.onboarding_verified_card +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.delay +import org.jetbrains.compose.resources.painterResource + +private val CardSize = 120.dp + +// The badge is drawn in the same 120-unit space as the card; its circle is centred here, so the +// scale-in pivots from the badge itself rather than the artwork centre. +private val BadgeTransformOrigin = TransformOrigin(92f / 120f, 24f / 120f) + +/** + * The card illustration on the analytics-consent step. The green "verified" badge is absent on the + * first frame and pops in a couple of seconds after the screen is shown. + */ +@Composable +internal fun OnboardingVerifiedCard(modifier: Modifier = Modifier) { + var badgeVisible by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + delay(2.seconds) + badgeVisible = true + } + VerifiedCard(badgeVisible = badgeVisible, modifier = modifier) +} + +@Composable +private fun VerifiedCard(badgeVisible: Boolean, modifier: Modifier = Modifier) { + val badgeScale by animateFloatAsState( + targetValue = if (badgeVisible) 1f else 0f, + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMediumLow), + label = "verified badge scale", + ) + val badgeAlpha by animateFloatAsState( + targetValue = if (badgeVisible) 1f else 0f, + animationSpec = tween(MotionTokens.DurationShort4.toInt(), easing = MotionTokens.EasingStandardCubicBezier), + label = "verified badge alpha", + ) + Box( + // Purely decorative: keep it out of the accessibility tree. + modifier = modifier + .size(CardSize) + .clearAndSetSemantics {}, + ) { + Spacer( + // Soft shadow matching the card silhouette (the card is inset within the 120dp artwork). + Modifier + .matchParentSize() + .padding(start = 8.dp, top = 16.dp, end = 20.dp, bottom = 12.dp) + .shadow(elevation = 6.dp, shape = RoundedCornerShape(24.dp)), + ) + Image( + painter = painterResource(Res.drawable.onboarding_verified_card), + contentDescription = null, + modifier = Modifier.matchParentSize(), + ) + Image( + painter = painterResource(Res.drawable.onboarding_verified_badge), + contentDescription = null, + modifier = Modifier + .matchParentSize() + .graphicsLayer { + transformOrigin = BadgeTransformOrigin + scaleX = badgeScale + scaleY = badgeScale + alpha = badgeAlpha + }, + ) + } +} + +@HedvigPreview +@Composable +private fun PreviewOnboardingVerifiedCardBadgeVisible() { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + VerifiedCard(badgeVisible = true, modifier = Modifier.padding(24.dp)) + } + } +} + +@HedvigPreview +@Composable +private fun PreviewOnboardingVerifiedCardBadgeHidden() { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + VerifiedCard(badgeVisible = false, modifier = Modifier.padding(24.dp)) + } + } +} From 85428fb8976f4dbe4a2715e919c4b1bb0c98dcb4 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 29 Jul 2026 13:40:43 +0200 Subject: [PATCH 059/119] Make keypad highlight start from active instead --- .../feature/onboarding/ui/phone/OnboardingPhoneKeypad.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneKeypad.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneKeypad.kt index 2855ab227b..a5da2e4796 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneKeypad.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneKeypad.kt @@ -52,12 +52,12 @@ private val KeypadRows = listOf( @Composable internal fun OnboardingPhoneKeypad(modifier: Modifier = Modifier) { - var entered by remember { mutableStateOf(false) } + var highlightActive by remember { mutableStateOf(true) } LaunchedEffect(Unit) { delay(2.seconds) entered = true } - KeypadGrid(highlightActive = entered, modifier = modifier) + KeypadGrid(highlightActive = highlightActive, modifier = modifier) } @Composable From f021f98a61c02c6345915683aae5876980d13443 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 29 Jul 2026 13:40:52 +0200 Subject: [PATCH 060/119] Make onboarding animations start a bit earleir --- .../feature/onboarding/ui/consent/OnboardingVerifiedCard.kt | 2 +- .../feature/onboarding/ui/phone/OnboardingPhoneKeypad.kt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingVerifiedCard.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingVerifiedCard.kt index a2c768a6e1..bfd77f21e5 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingVerifiedCard.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingVerifiedCard.kt @@ -47,7 +47,7 @@ private val BadgeTransformOrigin = TransformOrigin(92f / 120f, 24f / 120f) internal fun OnboardingVerifiedCard(modifier: Modifier = Modifier) { var badgeVisible by remember { mutableStateOf(false) } LaunchedEffect(Unit) { - delay(2.seconds) + delay(1.seconds) badgeVisible = true } VerifiedCard(badgeVisible = badgeVisible, modifier = modifier) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneKeypad.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneKeypad.kt index a5da2e4796..b2cf101eb6 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneKeypad.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneKeypad.kt @@ -54,8 +54,8 @@ private val KeypadRows = listOf( internal fun OnboardingPhoneKeypad(modifier: Modifier = Modifier) { var highlightActive by remember { mutableStateOf(true) } LaunchedEffect(Unit) { - delay(2.seconds) - entered = true + delay(1.seconds) + highlightActive = false } KeypadGrid(highlightActive = highlightActive, modifier = modifier) } From 56b477725069624d31c6e3ee72a67831660f44df Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 29 Jul 2026 15:31:37 +0200 Subject: [PATCH 061/119] Add a Usage data drill-in screen to Settings for analytics consent The Settings analytics-consent row now matches the Figma: a 'Usage data' row (Enabled/Disabled/Not set) that drills into a dedicated screen with the explanatory copy, a privacy-policy link, and Allow/Deny buttons, instead of an inline toggle. - New internal UsageDataKey + entry in feature-profile - UsageDataDestination screen + view model writes consent to SettingsDataStore and pops back; reuses the onboarding analytics copy - Settings row navigates into it; the inline ChangeAnalyticsConsent event is removed (the drill-in screen owns the write) - :app threads openPrivacyPolicy into profileEntries --- .../app/navigation/HedvigEntryProvider.kt | 1 + .../profile/navigation/ProfileDestinations.kt | 3 + .../profile/settings/SettingsDestination.kt | 30 ++- .../profile/settings/SettingsPresenter.kt | 6 - .../usagedata/UsageDataDestination.kt | 180 ++++++++++++++++++ .../feature/profile/tab/ProfileEntries.kt | 14 ++ 6 files changed, 210 insertions(+), 24 deletions(-) create mode 100644 app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/settings/usagedata/UsageDataDestination.kt 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 bb9f264613..bb00ec20b7 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 @@ -493,6 +493,7 @@ private fun EntryProviderScope.addProfileEntries( onNavigateToTravelCertificate = { backstack.add(TravelCertificateKey) }, onNavigateToInsuranceEvidence = { backstack.add(InsuranceEvidenceKey) }, openUrl = openUrl, + openPrivacyPolicy = { openUrl(privacyPolicyUrl(languageService.getLanguage())) }, navigateToChipId = { backstack.add(ChipIdKey()) }, languageService = languageService, onResetOnboardingForDebug = onResetOnboardingForDebug, diff --git a/app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/navigation/ProfileDestinations.kt b/app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/navigation/ProfileDestinations.kt index b9ed62ac44..7b85dda8ae 100644 --- a/app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/navigation/ProfileDestinations.kt +++ b/app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/navigation/ProfileDestinations.kt @@ -28,3 +28,6 @@ internal data object LicensesKey : HedvigNavKey @Serializable internal data object SettingsKey : HedvigNavKey + +@Serializable +internal data object UsageDataKey : HedvigNavKey diff --git a/app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/settings/SettingsDestination.kt b/app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/settings/SettingsDestination.kt index 32fbc075d5..f5ed5def9e 100644 --- a/app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/settings/SettingsDestination.kt +++ b/app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/settings/SettingsDestination.kt @@ -81,6 +81,7 @@ internal fun SettingsDestination( navigateUp: () -> Unit, openAppSettings: () -> Unit, onNavigateToDeleteAccountFeature: () -> Unit, + onNavigateToUsageData: () -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() SettingsScreen( @@ -95,7 +96,7 @@ internal fun SettingsDestination( changeSubscriptionPreference = { viewModel.emit(SettingsEvent.ChangeSubscriptionPreference(it)) }, - onChangeAnalyticsConsent = { viewModel.emit(SettingsEvent.ChangeAnalyticsConsent(it)) }, + onNavigateToUsageData = onNavigateToUsageData, ) } @@ -105,7 +106,7 @@ private fun SettingsScreen( notificationPermissionState: NotificationPermissionState, navigateUp: () -> Unit, changeSubscriptionPreference: (Boolean) -> Unit, - onChangeAnalyticsConsent: (AnalyticsConsent) -> Unit, + onNavigateToUsageData: () -> Unit, openAppSettings: () -> Unit, onNotificationInfoDismissed: () -> Unit, onLanguageSelected: (Language) -> Unit, @@ -176,34 +177,27 @@ private fun SettingsScreen( ) Spacer(Modifier.height(4.dp)) HedvigBigCard( - onClick = { - val newConsent = if (uiState.analyticsConsent == AnalyticsConsent.GRANTED) { - AnalyticsConsent.DENIED - } else { - AnalyticsConsent.GRANTED - } - onChangeAnalyticsConsent(newConsent) - }, + onClick = onNavigateToUsageData, inputText = when (uiState.analyticsConsent) { AnalyticsConsent.GRANTED -> { - // TODO: Add "On" / "På" to Lokalise - "On" + // TODO: Add "Enabled" / "Aktiverad" to Lokalise + "Enabled" } AnalyticsConsent.DENIED -> { - // TODO: Add "Off" / "Av" to Lokalise - "Off" + // TODO: Add "Disabled" / "Inaktiverad" to Lokalise + "Disabled" } // No explicit choice made yet: nothing is forwarded to Firebase, but this is shown - // as distinct from an explicit "Off". + // as distinct from an explicit "Disabled". AnalyticsConsent.NOT_DECIDED, null -> { // TODO: Add "Not set" / "Inte valt" to Lokalise "Not set" } }, - // TODO: Add "Product analytics" / "Produktanalys" to Lokalise - labelText = "Product analytics", + // TODO: Add "Usage data" / "Användningsdata" to Lokalise + labelText = "Usage data", modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), @@ -438,7 +432,7 @@ fun PreviewSettingsScreen() { onThemeSelected = {}, onTerminateAccountClicked = {}, changeSubscriptionPreference = {}, - onChangeAnalyticsConsent = {}, + onNavigateToUsageData = {}, ) } } diff --git a/app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/settings/SettingsPresenter.kt b/app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/settings/SettingsPresenter.kt index e372b3e214..b5e6604411 100644 --- a/app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/settings/SettingsPresenter.kt +++ b/app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/settings/SettingsPresenter.kt @@ -71,10 +71,6 @@ internal class SettingsPresenter( } } } - - is SettingsEvent.ChangeAnalyticsConsent -> { - launch { settingsDataStore.setAnalyticsConsent(event.consent) } - } } } @@ -133,6 +129,4 @@ sealed interface SettingsEvent { data class ChangeSubscriptionPreference(val subscribe: Boolean) : SettingsEvent data object SnoozeNotificationPermissionReminder : SettingsEvent - - data class ChangeAnalyticsConsent(val consent: AnalyticsConsent) : SettingsEvent } diff --git a/app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/settings/usagedata/UsageDataDestination.kt b/app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/settings/usagedata/UsageDataDestination.kt new file mode 100644 index 0000000000..7b1ddce6e3 --- /dev/null +++ b/app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/settings/usagedata/UsageDataDestination.kt @@ -0,0 +1,180 @@ +package com.hedvig.android.feature.profile.settings.usagedata + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.hedvig.android.core.common.di.ActivityRetainedScope +import com.hedvig.android.core.common.di.HedvigViewModel +import com.hedvig.android.data.settings.datastore.AnalyticsConsent +import com.hedvig.android.data.settings.datastore.SettingsDataStore +import com.hedvig.android.design.system.hedvig.ButtonDefaults +import com.hedvig.android.design.system.hedvig.HedvigButton +import com.hedvig.android.design.system.hedvig.HedvigPreview +import com.hedvig.android.design.system.hedvig.HedvigScaffold +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.Icon +import com.hedvig.android.design.system.hedvig.Surface +import com.hedvig.android.design.system.hedvig.icon.ArrowNorthEast +import com.hedvig.android.design.system.hedvig.icon.HedvigIcons +import com.hedvig.android.molecule.public.MoleculePresenter +import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.molecule.public.MoleculeViewModel +import dev.zacsweers.metro.Inject +import hedvig.resources.LEGAL_PRIVACY_POLICY +import hedvig.resources.ONBOARDING_ANALYTICS_ALLOW_BUTTON +import hedvig.resources.ONBOARDING_ANALYTICS_DENY_BUTTON +import hedvig.resources.ONBOARDING_ANALYTICS_SUBTITLE +import hedvig.resources.ONBOARDING_ANALYTICS_TITLE +import hedvig.resources.Res +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource + +@Inject +@HedvigViewModel(ActivityRetainedScope::class) +internal class UsageDataViewModel( + settingsDataStore: SettingsDataStore, +) : MoleculeViewModel( + initialState = UsageDataUiState(), + presenter = UsageDataPresenter(settingsDataStore), + ) + +internal class UsageDataPresenter( + private val settingsDataStore: SettingsDataStore, +) : MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present(lastState: UsageDataUiState): UsageDataUiState { + var finished by remember { mutableStateOf(lastState.finished) } + CollectEvents { event -> + val consent = when (event) { + UsageDataEvent.Allow -> AnalyticsConsent.GRANTED + UsageDataEvent.Deny -> AnalyticsConsent.DENIED + } + launch { + settingsDataStore.setAnalyticsConsent(consent) + finished = true + } + } + return UsageDataUiState(finished = finished) + } +} + +internal data class UsageDataUiState(val finished: Boolean = false) + +internal sealed interface UsageDataEvent { + data object Allow : UsageDataEvent + + data object Deny : UsageDataEvent +} + +@Composable +internal fun UsageDataDestination( + viewModel: UsageDataViewModel, + navigateUp: () -> Unit, + popBackstack: () -> Unit, + onPrivacyPolicy: () -> Unit, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + LaunchedEffect(uiState.finished) { + if (uiState.finished) popBackstack() + } + UsageDataScreen( + navigateUp = navigateUp, + onAllow = { viewModel.emit(UsageDataEvent.Allow) }, + onDeny = { viewModel.emit(UsageDataEvent.Deny) }, + onPrivacyPolicy = onPrivacyPolicy, + ) +} + +@Composable +private fun UsageDataScreen( + navigateUp: () -> Unit, + onAllow: () -> Unit, + onDeny: () -> Unit, + onPrivacyPolicy: () -> Unit, +) { + HedvigScaffold( + // TODO: Add "Usage data" / "Användningsdata" to Lokalise + topAppBarText = "Usage data", + navigateUp = navigateUp, + ) { + Spacer(Modifier.height(8.dp)) + Column(Modifier.padding(horizontal = 16.dp)) { + HedvigText(text = stringResource(Res.string.ONBOARDING_ANALYTICS_TITLE)) + Spacer(Modifier.height(4.dp)) + HedvigText( + text = stringResource(Res.string.ONBOARDING_ANALYTICS_SUBTITLE), + color = HedvigTheme.colorScheme.textSecondary, + ) + } + Spacer(Modifier.weight(1f)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .clip(CircleShape) + .clickable(onClick = onPrivacyPolicy) + .padding(horizontal = 8.dp, vertical = 4.dp), + ) { + HedvigText( + text = stringResource(Res.string.LEGAL_PRIVACY_POLICY), + style = HedvigTheme.typography.bodySmall, + textDecoration = TextDecoration.Underline, + ) + Icon( + imageVector = HedvigIcons.ArrowNorthEast, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + } + Spacer(Modifier.height(16.dp)) + HedvigButton( + text = stringResource(Res.string.ONBOARDING_ANALYTICS_ALLOW_BUTTON), + onClick = onAllow, + enabled = true, + buttonStyle = ButtonDefaults.ButtonStyle.Secondary, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + Spacer(Modifier.height(8.dp)) + HedvigButton( + text = stringResource(Res.string.ONBOARDING_ANALYTICS_DENY_BUTTON), + onClick = onDeny, + enabled = true, + buttonStyle = ButtonDefaults.ButtonStyle.Secondary, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + Spacer(Modifier.height(16.dp)) + } +} + +@HedvigPreview +@Composable +private fun PreviewUsageDataScreen() { + HedvigTheme { + Surface { + UsageDataScreen(navigateUp = {}, onAllow = {}, onDeny = {}, onPrivacyPolicy = {}) + } + } +} 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 052b50f9a2..d962d244d8 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 @@ -22,8 +22,11 @@ import com.hedvig.android.feature.profile.navigation.InformationKey import com.hedvig.android.feature.profile.navigation.LicensesKey import com.hedvig.android.feature.profile.navigation.ProfileKey import com.hedvig.android.feature.profile.navigation.SettingsKey +import com.hedvig.android.feature.profile.navigation.UsageDataKey import com.hedvig.android.feature.profile.settings.SettingsDestination import com.hedvig.android.feature.profile.settings.SettingsViewModel +import com.hedvig.android.feature.profile.settings.usagedata.UsageDataDestination +import com.hedvig.android.feature.profile.settings.usagedata.UsageDataViewModel import com.hedvig.android.language.LanguageService import com.hedvig.android.navigation.common.HedvigNavKey import com.hedvig.android.navigation.compose.Backstack @@ -47,6 +50,7 @@ fun EntryProviderScope.profileEntries( onNavigateToTravelCertificate: () -> Unit, onNavigateToInsuranceEvidence: () -> Unit, openUrl: (String) -> Unit, + openPrivacyPolicy: () -> Unit, navigateToChipId: () -> Unit, languageService: LanguageService, onResetOnboardingForDebug: () -> Unit, @@ -137,6 +141,16 @@ fun EntryProviderScope.profileEntries( navigateUp = backstack::navigateUp, openAppSettings = openAppSettings, onNavigateToDeleteAccountFeature = dropUnlessResumed { navigateToDeleteAccountFeature() }, + onNavigateToUsageData = dropUnlessResumed { backstack.add(UsageDataKey) }, + ) + } + entry(metadata = NavSuiteSceneDecoratorStrategy.showNavBar()) { + val viewModel: UsageDataViewModel = metroViewModel() + UsageDataDestination( + viewModel = viewModel, + navigateUp = backstack::navigateUp, + popBackstack = backstack::popBackstack, + onPrivacyPolicy = openPrivacyPolicy, ) } settingsDestinationNestedGraphs() From 0023f30408f346495cc60d3773da34e6a76ff551 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 29 Jul 2026 16:10:06 +0200 Subject: [PATCH 062/119] Use Lokalise strings for the Usage data settings row and screen Replace the placeholder literals with the downloaded keys: - SETTINGS_USAGE_DATA_TITLE (row label + screen title) - GENERAL_ENABLED / GENERAL_DISABLED (row value) - not_selected for the not-decided case Includes the regenerated strings.xml from downloadStrings, which also drops four unused ADDON_REMOVE_* keys (not referenced anywhere in code). --- .../androidMain/res/values-sv-rSE/strings.xml | 7 +++---- .../src/androidMain/res/values/strings.xml | 7 +++---- .../values-sv-rSE/strings.xml | 7 +++---- .../composeResources/values/strings.xml | 7 +++---- .../profile/settings/SettingsDestination.kt | 21 +++++++------------ .../usagedata/UsageDataDestination.kt | 4 ++-- 6 files changed, 21 insertions(+), 32 deletions(-) diff --git a/app/core/core-resources/src/androidMain/res/values-sv-rSE/strings.xml b/app/core/core-resources/src/androidMain/res/values-sv-rSE/strings.xml index 1923b97828..31f6fb86ae 100644 --- a/app/core/core-resources/src/androidMain/res/values-sv-rSE/strings.xml +++ b/app/core/core-resources/src/androidMain/res/values-sv-rSE/strings.xml @@ -63,10 +63,6 @@ Utöka skydd Du kan utöka skyddet för det här tillägget. Det kan tas bort vid förnyelse eller uppsägning av försäkringen. Se vad som ingår - Behåll nuvarande skydd - Avsluta %1$s - Om du avslutar detta tillägg ingår inte det extra skyddet längre i din hemförsäkring. - Avsluta %1$s Extra trygghet när du reser Res upp till 60 dagar i sträck Försenat flyg och bagage @@ -423,7 +419,9 @@ Din försäkring kommer att uppdateras den %1$s. Bekräfta Kontakta vårt serviceteam + Inaktiverad Ladda ner + Aktiverad Felaktig inmatning Vet inte Försök igen @@ -792,6 +790,7 @@ Systemets standardinställning Tema Inställningar + Användningsdata Märke Fyll i ditt inköpspris Fortsätt diff --git a/app/core/core-resources/src/androidMain/res/values/strings.xml b/app/core/core-resources/src/androidMain/res/values/strings.xml index e2a61ba388..e32435aa59 100644 --- a/app/core/core-resources/src/androidMain/res/values/strings.xml +++ b/app/core/core-resources/src/androidMain/res/values/strings.xml @@ -63,10 +63,6 @@ Extend coverage You can extend the coverage of this addon. It can only be removed when your insurance is renewed or cancelled. See what\'s included - Keep current coverage - Cancel %1$s - By canceling this extended coverage, your home insurance will no longer include this extra protection. - Cancel %1$s Add extra safety when traveling Travel up to 60 days in a row Delayed bags and flights @@ -423,7 +419,9 @@ Your insurance will be updated starting from %1$s Confirm Contact our service team + Disabled Download + Enabled Invalid input Not sure Try again @@ -792,6 +790,7 @@ System default Theme Settings + Usage data Brand Fill in your purchase price Continue diff --git a/app/core/core-resources/src/commonMain/composeResources/values-sv-rSE/strings.xml b/app/core/core-resources/src/commonMain/composeResources/values-sv-rSE/strings.xml index 72ad986fd4..6e40825f82 100644 --- a/app/core/core-resources/src/commonMain/composeResources/values-sv-rSE/strings.xml +++ b/app/core/core-resources/src/commonMain/composeResources/values-sv-rSE/strings.xml @@ -62,10 +62,6 @@ Utöka skydd Du kan utöka skyddet för det här tillägget. Det kan tas bort vid förnyelse eller uppsägning av försäkringen. Se vad som ingår - Behåll nuvarande skydd - Avsluta %1$s - Om du avslutar detta tillägg ingår inte det extra skyddet längre i din hemförsäkring. - Avsluta %1$s Extra trygghet när du reser Res upp till 60 dagar i sträck Försenat flyg och bagage @@ -422,7 +418,9 @@ Din försäkring kommer att uppdateras den %1$s. Bekräfta Kontakta vårt serviceteam + Inaktiverad Ladda ner + Aktiverad Felaktig inmatning Vet inte Försök igen @@ -791,6 +789,7 @@ Systemets standardinställning Tema Inställningar + Användningsdata Märke Fyll i ditt inköpspris Fortsätt diff --git a/app/core/core-resources/src/commonMain/composeResources/values/strings.xml b/app/core/core-resources/src/commonMain/composeResources/values/strings.xml index 359175c117..09d4fd1d95 100644 --- a/app/core/core-resources/src/commonMain/composeResources/values/strings.xml +++ b/app/core/core-resources/src/commonMain/composeResources/values/strings.xml @@ -62,10 +62,6 @@ Extend coverage You can extend the coverage of this addon. It can only be removed when your insurance is renewed or cancelled. See what's included - Keep current coverage - Cancel %1$s - By canceling this extended coverage, your home insurance will no longer include this extra protection. - Cancel %1$s Add extra safety when traveling Travel up to 60 days in a row Delayed bags and flights @@ -422,7 +418,9 @@ Your insurance will be updated starting from %1$s Confirm Contact our service team + Disabled Download + Enabled Invalid input Not sure Try again @@ -791,6 +789,7 @@ System default Theme Settings + Usage data Brand Fill in your purchase price Continue diff --git a/app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/settings/SettingsDestination.kt b/app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/settings/SettingsDestination.kt index f5ed5def9e..a52143c07d 100644 --- a/app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/settings/SettingsDestination.kt +++ b/app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/settings/SettingsDestination.kt @@ -54,6 +54,8 @@ import com.hedvig.android.notification.permission.NotificationPermissionDialog import com.hedvig.android.notification.permission.NotificationPermissionState import com.hedvig.android.notification.permission.rememberNotificationPermissionState import com.hedvig.android.theme.Theme +import hedvig.resources.GENERAL_DISABLED +import hedvig.resources.GENERAL_ENABLED import hedvig.resources.GENERAL_SUBSCRIBED import hedvig.resources.GENERAL_UNSUBSCRIBED import hedvig.resources.PROFILE_NOTIFICATIONS_STATUS_OFF @@ -69,6 +71,7 @@ import hedvig.resources.SETTINGS_THEME_LIGHT import hedvig.resources.SETTINGS_THEME_SYSTEM_DEFAULT import hedvig.resources.SETTINGS_THEME_TITLE import hedvig.resources.SETTINGS_TITLE +import hedvig.resources.SETTINGS_USAGE_DATA_TITLE import hedvig.resources.general_close_button import hedvig.resources.language_picker_modal_title import hedvig.resources.not_selected @@ -179,25 +182,15 @@ private fun SettingsScreen( HedvigBigCard( onClick = onNavigateToUsageData, inputText = when (uiState.analyticsConsent) { - AnalyticsConsent.GRANTED -> { - // TODO: Add "Enabled" / "Aktiverad" to Lokalise - "Enabled" - } + AnalyticsConsent.GRANTED -> stringResource(Res.string.GENERAL_ENABLED) - AnalyticsConsent.DENIED -> { - // TODO: Add "Disabled" / "Inaktiverad" to Lokalise - "Disabled" - } + AnalyticsConsent.DENIED -> stringResource(Res.string.GENERAL_DISABLED) // No explicit choice made yet: nothing is forwarded to Firebase, but this is shown // as distinct from an explicit "Disabled". - AnalyticsConsent.NOT_DECIDED, null -> { - // TODO: Add "Not set" / "Inte valt" to Lokalise - "Not set" - } + AnalyticsConsent.NOT_DECIDED, null -> stringResource(Res.string.not_selected) }, - // TODO: Add "Usage data" / "Användningsdata" to Lokalise - labelText = "Usage data", + labelText = stringResource(Res.string.SETTINGS_USAGE_DATA_TITLE), modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), diff --git a/app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/settings/usagedata/UsageDataDestination.kt b/app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/settings/usagedata/UsageDataDestination.kt index 7b1ddce6e3..f32bee3acc 100644 --- a/app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/settings/usagedata/UsageDataDestination.kt +++ b/app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/settings/usagedata/UsageDataDestination.kt @@ -45,6 +45,7 @@ import hedvig.resources.ONBOARDING_ANALYTICS_DENY_BUTTON import hedvig.resources.ONBOARDING_ANALYTICS_SUBTITLE import hedvig.resources.ONBOARDING_ANALYTICS_TITLE import hedvig.resources.Res +import hedvig.resources.SETTINGS_USAGE_DATA_TITLE import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource @@ -112,8 +113,7 @@ private fun UsageDataScreen( onPrivacyPolicy: () -> Unit, ) { HedvigScaffold( - // TODO: Add "Usage data" / "Användningsdata" to Lokalise - topAppBarText = "Usage data", + topAppBarText = stringResource(Res.string.SETTINGS_USAGE_DATA_TITLE), navigateUp = navigateUp, ) { Spacer(Modifier.height(8.dp)) From 547741bd01fd2f3dc8e6333fe23a36231bc2f548 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 29 Jul 2026 17:06:53 +0200 Subject: [PATCH 063/119] Small UI tweaks --- .../onboarding/ui/OnboardingStepScaffold.kt | 29 +++++++++++-------- .../ui/consent/OnboardingVerifiedCard.kt | 3 +- .../welcome/OnboardingWelcomeDestination.kt | 7 ++++- 3 files changed, 24 insertions(+), 15 deletions(-) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt index d23a5d2b75..0dabaa65f3 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt @@ -25,9 +25,11 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.unit.dp import androidx.navigation3.ui.LocalNavAnimatedContentScope import com.hedvig.android.compose.ui.LocalSharedTransitionScope +import com.hedvig.android.compose.ui.withoutPlacement import com.hedvig.android.design.system.hedvig.ButtonDefaults import com.hedvig.android.design.system.hedvig.HedvigButton import com.hedvig.android.design.system.hedvig.HedvigPreview @@ -110,18 +112,21 @@ internal fun OnboardingStepScaffold( .height(56.dp) .padding(horizontal = 16.dp), ) { - if (showBackButton) { - IconButton( - onClick = onBackClick, - ) { - Icon( - imageVector = HedvigIcons.ArrowLeft, - contentDescription = stringResource(Res.string.general_back_button), - modifier = Modifier.size(24.dp), - ) - } - } else { - Spacer(Modifier.size(40.dp)) + IconButton( + onClick = onBackClick, + modifier = if (!showBackButton) { + Modifier + .clearAndSetSemantics {} + .withoutPlacement() + } else { + Modifier + }, + ) { + Icon( + imageVector = HedvigIcons.ArrowLeft, + contentDescription = stringResource(Res.string.general_back_button), + modifier = Modifier.size(24.dp), + ) } if (progress != null) { OnboardingProgressBar( diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingVerifiedCard.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingVerifiedCard.kt index bfd77f21e5..dead603aef 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingVerifiedCard.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingVerifiedCard.kt @@ -66,7 +66,6 @@ private fun VerifiedCard(badgeVisible: Boolean, modifier: Modifier = Modifier) { label = "verified badge alpha", ) Box( - // Purely decorative: keep it out of the accessibility tree. modifier = modifier .size(CardSize) .clearAndSetSemantics {}, @@ -76,7 +75,7 @@ private fun VerifiedCard(badgeVisible: Boolean, modifier: Modifier = Modifier) { Modifier .matchParentSize() .padding(start = 8.dp, top = 16.dp, end = 20.dp, bottom = 12.dp) - .shadow(elevation = 6.dp, shape = RoundedCornerShape(24.dp)), + .shadow(elevation = 4.dp, shape = RoundedCornerShape(24.dp)), ) Image( painter = painterResource(Res.drawable.onboarding_verified_card), diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt index 14d80b591a..cd08b9e0a7 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt @@ -16,6 +16,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.LineBreak import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -25,6 +26,7 @@ import com.hedvig.android.design.system.hedvig.HedvigErrorSection import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgressDebounced import com.hedvig.android.design.system.hedvig.HedvigText import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.LocalTextStyle import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator import com.hedvig.android.feature.onboarding.ui.OnboardingProgress @@ -136,7 +138,7 @@ internal fun OnboardingWelcomeDestination(viewModel: OnboardingWelcomeViewModel) Image( painter = painterResource(Res.drawable.onboarding_hedvig_symbol), contentDescription = null, - modifier = Modifier.size(96.dp), + modifier = Modifier.size(128.dp), ) Spacer(Modifier.height(24.dp)) HedvigText( @@ -147,6 +149,9 @@ internal fun OnboardingWelcomeDestination(viewModel: OnboardingWelcomeViewModel) text = stringResource(Res.string.ONBOARDING_WELCOME_SUBTITLE), color = HedvigTheme.colorScheme.textSecondary, textAlign = TextAlign.Center, + style = LocalTextStyle.current.copy( + lineBreak = LineBreak.Heading, + ) ) } Spacer(Modifier.weight(1f)) From f4fc52055dc8f45fe37b5c2fe7a2b5195ef4b0e2 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 29 Jul 2026 17:07:40 +0200 Subject: [PATCH 064/119] Remove double spacer --- .../feature/onboarding/ui/phone/OnboardingPhoneDestination.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt index 0ff849bc42..b832a2e32d 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt @@ -214,7 +214,6 @@ internal fun OnboardingPhoneDestination(viewModel: OnboardingPhoneViewModel, nav .fillMaxWidth() .padding(horizontal = 16.dp), ) - Spacer(Modifier.height(16.dp)) OnboardingStepButtons( primaryText = stringResource(Res.string.general_save_button), onPrimaryClick = { viewModel.emit(OnboardingPhoneEvent.Save(phoneNumberState.text.toString())) }, From d9b4b683b3a83dbd4b4049f753010ced0a07819c Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 29 Jul 2026 17:09:24 +0200 Subject: [PATCH 065/119] Tweak keypad button corners --- .../feature/onboarding/ui/phone/OnboardingPhoneKeypad.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneKeypad.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneKeypad.kt index b2cf101eb6..31a9398246 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneKeypad.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneKeypad.kt @@ -111,7 +111,7 @@ private fun KeypadKey(label: String, highlighted: Boolean) { scaleX = scale scaleY = scale } - .background(containerColor, HedvigTheme.shapes.cornerXLarge), + .background(containerColor, HedvigTheme.shapes.cornerLarge), contentAlignment = Alignment.Center, ) { HedvigText( From 53a1e778edda759156990cb249c4f10ee54e15dc Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 29 Jul 2026 17:10:59 +0200 Subject: [PATCH 066/119] Add pop-in notification badge to onboarding welcome symbol --- .../welcome/OnboardingWelcomeDestination.kt | 12 +- .../ui/welcome/OnboardingWelcomeSymbol.kt | 115 ++++++++++++++++++ 2 files changed, 117 insertions(+), 10 deletions(-) create mode 100644 app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeSymbol.kt diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt index cd08b9e0a7..5381156f01 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt @@ -1,12 +1,10 @@ package com.hedvig.android.feature.onboarding.ui.welcome -import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -41,9 +39,7 @@ import hedvig.resources.ONBOARDING_WELCOME_BUTTON import hedvig.resources.ONBOARDING_WELCOME_SUBTITLE import hedvig.resources.ONBOARDING_WELCOME_TITLE import hedvig.resources.Res -import hedvig.resources.onboarding_hedvig_symbol import kotlinx.coroutines.launch -import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource @Inject @@ -135,11 +131,7 @@ internal fun OnboardingWelcomeDestination(viewModel: OnboardingWelcomeViewModel) .fillMaxWidth() .padding(horizontal = 16.dp), ) { - Image( - painter = painterResource(Res.drawable.onboarding_hedvig_symbol), - contentDescription = null, - modifier = Modifier.size(128.dp), - ) + OnboardingWelcomeSymbol() Spacer(Modifier.height(24.dp)) HedvigText( text = stringResource(Res.string.ONBOARDING_WELCOME_TITLE), @@ -151,7 +143,7 @@ internal fun OnboardingWelcomeDestination(viewModel: OnboardingWelcomeViewModel) textAlign = TextAlign.Center, style = LocalTextStyle.current.copy( lineBreak = LineBreak.Heading, - ) + ), ) } Spacer(Modifier.weight(1f)) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeSymbol.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeSymbol.kt new file mode 100644 index 0000000000..c78f97092d --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeSymbol.kt @@ -0,0 +1,115 @@ +package com.hedvig.android.feature.onboarding.ui.welcome + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.unit.dp +import com.hedvig.android.design.system.hedvig.HedvigPreview +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.Surface +import com.hedvig.android.design.system.hedvig.tokens.MotionTokens +import hedvig.resources.Res +import hedvig.resources.onboarding_hedvig_symbol +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.delay +import org.jetbrains.compose.resources.painterResource + +private val SymbolSize = 128.dp +private val BadgeSize = 32.dp + +/** + * The Hedvig symbol on the welcome step, with a red notification badge that is absent on the first + * frame and pops in a couple of seconds after the screen is shown, mirroring the verified badge on + * the analytics-consent step. + */ +@Composable +internal fun OnboardingWelcomeSymbol(modifier: Modifier = Modifier) { + var badgeVisible by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + delay(1.seconds) + badgeVisible = true + } + WelcomeSymbol(badgeVisible = badgeVisible, modifier = modifier) +} + +@Composable +private fun WelcomeSymbol(badgeVisible: Boolean, modifier: Modifier = Modifier) { + val badgeScale by animateFloatAsState( + targetValue = if (badgeVisible) 1f else 0f, + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMediumLow), + label = "welcome badge scale", + ) + val badgeAlpha by animateFloatAsState( + targetValue = if (badgeVisible) 1f else 0f, + animationSpec = tween(MotionTokens.DurationShort4.toInt(), easing = MotionTokens.EasingStandardCubicBezier), + label = "welcome badge alpha", + ) + Box( + modifier = modifier + .size(SymbolSize) + .clearAndSetSemantics {}, + ) { + Image( + painter = painterResource(Res.drawable.onboarding_hedvig_symbol), + contentDescription = null, + modifier = Modifier.matchParentSize(), + ) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .align(Alignment.TopEnd) + .size(BadgeSize) + .graphicsLayer { + scaleX = badgeScale + scaleY = badgeScale + alpha = badgeAlpha + } + .clip(CircleShape) + .background(HedvigTheme.colorScheme.signalRedElement), + ) { + HedvigText( + text = "1", + color = HedvigTheme.colorScheme.textWhite, + style = HedvigTheme.typography.label, + ) + } + } +} + +@HedvigPreview +@Composable +private fun PreviewOnboardingWelcomeSymbolBadgeVisible() { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + WelcomeSymbol(badgeVisible = true) + } + } +} + +@HedvigPreview +@Composable +private fun PreviewOnboardingWelcomeSymbolBadgeHidden() { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + WelcomeSymbol(badgeVisible = false) + } + } +} From b37a04b1bef2863845459bea42a59d79e546f273 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 29 Jul 2026 17:14:57 +0200 Subject: [PATCH 067/119] Create preview for OnboardingThemeDestination.kt --- .../ui/theme/OnboardingThemeDestination.kt | 49 ++++++++++++++++--- 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt index c562840818..db71af0efd 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt @@ -21,6 +21,7 @@ import com.hedvig.android.core.common.di.HedvigViewModel import com.hedvig.android.data.settings.datastore.SettingsDataStore import com.hedvig.android.design.system.hedvig.HedvigErrorSection import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgressDebounced +import com.hedvig.android.design.system.hedvig.HedvigPreview import com.hedvig.android.design.system.hedvig.HedvigText import com.hedvig.android.design.system.hedvig.HedvigTheme import com.hedvig.android.design.system.hedvig.IconResource @@ -141,11 +142,30 @@ internal sealed interface OnboardingThemeEvent { @Composable internal fun OnboardingThemeDestination(viewModel: OnboardingThemeViewModel, navigateUp: () -> Unit) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + OnboardingThemeDestination( + uiState = uiState, + navigateUp = navigateUp, + onCloseClick = { viewModel.emit(OnboardingThemeEvent.Close) }, + onRetry = { viewModel.emit(OnboardingThemeEvent.Retry) }, + onSelectTheme = { viewModel.emit(OnboardingThemeEvent.SelectTheme(it)) }, + onContinueClick = { viewModel.emit(OnboardingThemeEvent.Continue) }, + ) +} + +@Composable +private fun OnboardingThemeDestination( + uiState: OnboardingThemeUiState, + navigateUp: () -> Unit, + onCloseClick: () -> Unit, + onRetry: () -> Unit, + onSelectTheme: (Theme) -> Unit, + onContinueClick: () -> Unit, +) { OnboardingStepScaffold( progress = (uiState as? OnboardingThemeUiState.Content)?.progress, showBackButton = true, onBackClick = navigateUp, - onCloseClick = { viewModel.emit(OnboardingThemeEvent.Close) }, + onCloseClick = onCloseClick, ) { when (uiState) { OnboardingThemeUiState.Loading -> { @@ -154,12 +174,11 @@ internal fun OnboardingThemeDestination(viewModel: OnboardingThemeViewModel, nav OnboardingThemeUiState.Error -> { HedvigErrorSection( - onButtonClick = { viewModel.emit(OnboardingThemeEvent.Retry) }, + onButtonClick = onRetry, ) } is OnboardingThemeUiState.Content -> { - val content = uiState as OnboardingThemeUiState.Content Spacer(Modifier.height(16.dp)) OnboardingStepHeader( title = stringResource(Res.string.SETTINGS_THEME_DIALOG_TITLE), @@ -188,8 +207,8 @@ internal fun OnboardingThemeDestination(viewModel: OnboardingThemeViewModel, nav iconResource = IconResource.Vector(HedvigIcons.CircleFilled), ), ), - selectedOption = RadioOptionId(content.selectedTheme.name), - onRadioOptionSelected = { viewModel.emit(OnboardingThemeEvent.SelectTheme(Theme.valueOf(it.id))) }, + selectedOption = RadioOptionId(uiState.selectedTheme.name), + onRadioOptionSelected = { onSelectTheme(Theme.valueOf(it.id)) }, modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), @@ -207,9 +226,27 @@ internal fun OnboardingThemeDestination(viewModel: OnboardingThemeViewModel, nav ) OnboardingStepButtons( primaryText = stringResource(Res.string.general_continue_button), - onPrimaryClick = { viewModel.emit(OnboardingThemeEvent.Continue) }, + onPrimaryClick = onContinueClick, ) } } } } + +@HedvigPreview +@Composable +private fun PreviewOnboardingThemeDestination() { + HedvigTheme { + OnboardingThemeDestination( + uiState = OnboardingThemeUiState.Content( + progress = OnboardingProgress(totalSteps = 5, currentIndex = 2), + selectedTheme = Theme.SYSTEM_DEFAULT, + ), + navigateUp = {}, + onCloseClick = {}, + onRetry = {}, + onSelectTheme = {}, + onContinueClick = {}, + ) + } +} From 1f23a1e10e09685751323e23634a45695c0ddc58 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 29 Jul 2026 17:32:27 +0200 Subject: [PATCH 068/119] Move welcome icon's badge position according to design --- .../feature/onboarding/ui/welcome/OnboardingWelcomeSymbol.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeSymbol.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeSymbol.kt index c78f97092d..b20291a774 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeSymbol.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeSymbol.kt @@ -33,7 +33,8 @@ import kotlinx.coroutines.delay import org.jetbrains.compose.resources.painterResource private val SymbolSize = 128.dp -private val BadgeSize = 32.dp +private val BadgeSize = 38.dp +private val BadgeOffset = 6.dp /** * The Hedvig symbol on the welcome step, with a red notification badge that is absent on the first @@ -78,6 +79,8 @@ private fun WelcomeSymbol(badgeVisible: Boolean, modifier: Modifier = Modifier) .align(Alignment.TopEnd) .size(BadgeSize) .graphicsLayer { + translationX = BadgeOffset.toPx() + translationY = -BadgeOffset.toPx() scaleX = badgeScale scaleY = badgeScale alpha = badgeAlpha From cf557e1ddc87d56ea36d60f73a336579520ee1c3 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 29 Jul 2026 17:38:19 +0200 Subject: [PATCH 069/119] Use the transparent Hedvig pebble for the onboarding welcome symbol --- .../drawable/onboarding_hedvig_symbol.webp | Bin 59842 -> 0 bytes .../ui/theme/OnboardingThemeDestination.kt | 160 ++++++++++++++---- .../ui/welcome/OnboardingWelcomeSymbol.kt | 4 +- 3 files changed, 132 insertions(+), 32 deletions(-) delete mode 100644 app/core/core-resources/src/commonMain/composeResources/drawable/onboarding_hedvig_symbol.webp diff --git a/app/core/core-resources/src/commonMain/composeResources/drawable/onboarding_hedvig_symbol.webp b/app/core/core-resources/src/commonMain/composeResources/drawable/onboarding_hedvig_symbol.webp deleted file mode 100644 index dccdfa8c2426fb3fb1d883d6cd0c80e7090ac5c4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 59842 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 z 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 Par?DG5CB!<-~a#sLO7y+ diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt index db71af0efd..83b40a7f1a 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt @@ -1,9 +1,18 @@ package com.hedvig.android.feature.onboarding.ui.theme +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.selection.selectable import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -12,7 +21,13 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -24,12 +39,7 @@ import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProg import com.hedvig.android.design.system.hedvig.HedvigPreview import com.hedvig.android.design.system.hedvig.HedvigText import com.hedvig.android.design.system.hedvig.HedvigTheme -import com.hedvig.android.design.system.hedvig.IconResource -import com.hedvig.android.design.system.hedvig.RadioGroup -import com.hedvig.android.design.system.hedvig.RadioOption -import com.hedvig.android.design.system.hedvig.RadioOptionId -import com.hedvig.android.design.system.hedvig.icon.CircleFilled -import com.hedvig.android.design.system.hedvig.icon.CircleOutline +import com.hedvig.android.design.system.hedvig.Surface import com.hedvig.android.design.system.hedvig.icon.HedvigIcons import com.hedvig.android.design.system.hedvig.icon.Settings import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore @@ -186,33 +196,34 @@ private fun OnboardingThemeDestination( ) Spacer(Modifier.weight(1f)) Spacer(Modifier.height(24.dp)) - RadioGroup( - options = listOf( - RadioOption( - id = RadioOptionId(Theme.SYSTEM_DEFAULT.name), - text = stringResource(Res.string.SETTINGS_THEME_SYSTEM_DEFAULT), - label = stringResource(Res.string.ONBOARDING_THEME_SYSTEM_SUBTITLE), - iconResource = IconResource.Vector(HedvigIcons.Settings), - ), - RadioOption( - id = RadioOptionId(Theme.LIGHT.name), - text = stringResource(Res.string.SETTINGS_THEME_LIGHT), - label = stringResource(Res.string.ONBOARDING_THEME_LIGHT_SUBTITLE), - iconResource = IconResource.Vector(HedvigIcons.CircleOutline), - ), - RadioOption( - id = RadioOptionId(Theme.DARK.name), - text = stringResource(Res.string.SETTINGS_THEME_DARK), - label = stringResource(Res.string.ONBOARDING_THEME_DARK_SUBTITLE), - iconResource = IconResource.Vector(HedvigIcons.CircleFilled), - ), - ), - selectedOption = RadioOptionId(uiState.selectedTheme.name), - onRadioOptionSelected = { onSelectTheme(Theme.valueOf(it.id)) }, + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), - ) + ) { + ThemeOptionRow( + theme = Theme.SYSTEM_DEFAULT, + title = stringResource(Res.string.SETTINGS_THEME_SYSTEM_DEFAULT), + description = stringResource(Res.string.ONBOARDING_THEME_SYSTEM_SUBTITLE), + selected = uiState.selectedTheme == Theme.SYSTEM_DEFAULT, + onClick = { onSelectTheme(Theme.SYSTEM_DEFAULT) }, + ) + ThemeOptionRow( + theme = Theme.LIGHT, + title = stringResource(Res.string.SETTINGS_THEME_LIGHT), + description = stringResource(Res.string.ONBOARDING_THEME_LIGHT_SUBTITLE), + selected = uiState.selectedTheme == Theme.LIGHT, + onClick = { onSelectTheme(Theme.LIGHT) }, + ) + ThemeOptionRow( + theme = Theme.DARK, + title = stringResource(Res.string.SETTINGS_THEME_DARK), + description = stringResource(Res.string.ONBOARDING_THEME_DARK_SUBTITLE), + selected = uiState.selectedTheme == Theme.DARK, + onClick = { onSelectTheme(Theme.DARK) }, + ) + } Spacer(Modifier.weight(1f)) Spacer(Modifier.height(24.dp)) HedvigText( @@ -233,6 +244,95 @@ private fun OnboardingThemeDestination( } } +@Composable +private fun ThemeOptionRow( + theme: Theme, + title: String, + description: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + shape = HedvigTheme.shapes.cornerLarge, + color = HedvigTheme.colorScheme.surfacePrimary, + modifier = modifier + .fillMaxWidth() + .selectable(selected = selected, role = Role.RadioButton, onClick = onClick), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(start = 12.dp, top = 16.dp, end = 16.dp, bottom = 16.dp), + ) { + Box( + Modifier + .clip(HedvigTheme.shapes.cornerSmall) + .background(HedvigTheme.colorScheme.surfaceSecondaryTransparent) + .padding(8.dp), + ) { + ThemeOptionIcon(theme) + } + Column(Modifier.weight(1f)) { + HedvigText(text = title, style = HedvigTheme.typography.bodySmall) + HedvigText( + text = description, + style = HedvigTheme.typography.label, + color = HedvigTheme.colorScheme.textSecondary, + ) + } + ThemeSelectionIndicator(selected) + } + } +} + +@Composable +private fun ThemeOptionIcon(theme: Theme) { + when (theme) { + Theme.SYSTEM_DEFAULT -> Image( + imageVector = HedvigIcons.Settings, + contentDescription = null, + colorFilter = ColorFilter.tint(HedvigTheme.colorScheme.fillPrimary), + modifier = Modifier.size(24.dp), + ) + + Theme.LIGHT -> ThemeColorCircle( + fillColor = LightThemeSwatchColor, + borderColor = HedvigTheme.colorScheme.borderSecondary, + ) + + Theme.DARK -> ThemeColorCircle( + fillColor = DarkThemeSwatchColor, + borderColor = null, + ) + } +} + +@Composable +private fun ThemeColorCircle(fillColor: Color, borderColor: Color?) { + Canvas(Modifier.size(24.dp)) { + val radius = 19.dp.toPx() / 2f + drawCircle(color = fillColor, radius = radius) + if (borderColor != null) { + val strokeWidth = 1.dp.toPx() + drawCircle(color = borderColor, radius = radius - strokeWidth / 2f, style = Stroke(strokeWidth)) + } + } +} + +@Composable +private fun ThemeSelectionIndicator(selected: Boolean) { + val color = if (selected) HedvigTheme.colorScheme.signalGreenElement else HedvigTheme.colorScheme.borderSecondary + Canvas(Modifier.size(24.dp)) { + val strokeWidth = if (selected) 8.dp.toPx() else 2.dp.toPx() + drawCircle(color = color, radius = size.minDimension / 2f - strokeWidth / 2f, style = Stroke(strokeWidth)) + } +} + +// Fixed light/dark swatches: they preview the theme itself, so they stay constant regardless of the active theme. +private val LightThemeSwatchColor = Color(0xFFFAFAFA) +private val DarkThemeSwatchColor = Color(0xFF121212) + @HedvigPreview @Composable private fun PreviewOnboardingThemeDestination() { diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeSymbol.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeSymbol.kt index b20291a774..c9e1d007b7 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeSymbol.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeSymbol.kt @@ -27,7 +27,7 @@ import com.hedvig.android.design.system.hedvig.HedvigTheme import com.hedvig.android.design.system.hedvig.Surface import com.hedvig.android.design.system.hedvig.tokens.MotionTokens import hedvig.resources.Res -import hedvig.resources.onboarding_hedvig_symbol +import hedvig.resources.pillow_hedvig import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.delay import org.jetbrains.compose.resources.painterResource @@ -69,7 +69,7 @@ private fun WelcomeSymbol(badgeVisible: Boolean, modifier: Modifier = Modifier) .clearAndSetSemantics {}, ) { Image( - painter = painterResource(Res.drawable.onboarding_hedvig_symbol), + painter = painterResource(Res.drawable.pillow_hedvig), contentDescription = null, modifier = Modifier.matchParentSize(), ) From 2f5ca18f3dc1c33a96d1944c3c2f6c4f2e54a421 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 29 Jul 2026 17:55:44 +0200 Subject: [PATCH 070/119] Adjust welcome screen label text size --- .../feature/onboarding/ui/welcome/OnboardingWelcomeSymbol.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeSymbol.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeSymbol.kt index c9e1d007b7..97462e896f 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeSymbol.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeSymbol.kt @@ -91,7 +91,7 @@ private fun WelcomeSymbol(badgeVisible: Boolean, modifier: Modifier = Modifier) HedvigText( text = "1", color = HedvigTheme.colorScheme.textWhite, - style = HedvigTheme.typography.label, + style = HedvigTheme.typography.bodySmall, ) } } From 40d187423a6b1dbe967c730a556e1ea278dfdad5 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 29 Jul 2026 18:41:51 +0200 Subject: [PATCH 071/119] Add spec for onboarding progress bar progression animation --- ...oarding-progress-bar-progression-design.md | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-29-onboarding-progress-bar-progression-design.md diff --git a/docs/superpowers/specs/2026-07-29-onboarding-progress-bar-progression-design.md b/docs/superpowers/specs/2026-07-29-onboarding-progress-bar-progression-design.md new file mode 100644 index 0000000000..505881ae5c --- /dev/null +++ b/docs/superpowers/specs/2026-07-29-onboarding-progress-bar-progression-design.md @@ -0,0 +1,124 @@ +# Onboarding progress bar: continuous progression during the shared transition + +## Problem + +Each onboarding step is a separate `NavEntry`, i.e. a separate composition +(`OnboardingEntries.kt`). The header `Row` in `OnboardingStepScaffold` is a single +shared element (`sharedBounds`, key `"onboarding-top-bar"`) pinned across steps. During a +step transition both the outgoing and incoming headers are composed at the same bounds and +`sharedBounds` crossfades their content. Because the two `OnboardingProgressBar`s differ +only in how many segments are filled, the user sees the old fill dissolve into the new one +instead of a fill growing left-to-right. + +## Goal + +Make the progress bar look like it is genuinely progressing left-to-right as the shared +transition runs: a single continuous fill that grows (or shrinks, on back navigation) from +the previous step's fraction to the current step's fraction, seamlessly, with no visible +crossfade between two different states. + +## Key insight + +The fill must be driven by a value that is **the same instance in both compositions and +animates across the transition**. If the outgoing and incoming bars both read the identical +value every frame, the `sharedBounds` crossfade becomes invisible (both sides look the same) +while the value animates, producing a clean growing fill. + +## Design + +### 1. Shared, flow-scoped animation holder (UI layer) + +A new `internal` class in the onboarding `ui` package, scoped to the onboarding flow's +lifetime (matching `OnboardingSessionStore`, which is `@SingleIn(ActivityRetainedScope::class)` +and survives across every step within one Activity): + +```kotlin +@Inject +@SingleIn(ActivityRetainedScope::class) +internal class OnboardingProgressBarAnimation { + private val animatable = Animatable(0f) + private var seeded = false + + val fraction: Float get() = animatable.value + + suspend fun moveTo(target: Float) { + if (!seeded) { + seeded = true + animatable.snapTo(target) + } else { + animatable.animateTo(target) + } + } +} +``` + +One `Animatable`, one instance for the whole flow. `fraction` is snapshot-backed, so both +the outgoing and incoming step recompose in lockstep as it animates. + +The `seeded` flag handles timing edge cases: the first value the holder ever sees snaps +(fresh flow launch, or process-death restore landing directly on a mid-path step), while +every later step-to-step transition animates from the current fill. Back navigation animates +the fill shrinking left, the natural inverse. + +### 2. Delivery via CompositionLocal + +A `CompositionLocal` defaulting to `null` (mirroring how the scaffold already reads +`LocalSharedTransitionScope` and tolerates `null` in isolated previews): + +```kotlin +internal val LocalOnboardingProgressBarAnimation = + staticCompositionLocalOf { null } +``` + +Each step's `ViewModel` gains the holder as a constructor dependency and exposes it as a +plain property. Each destination's top-level composable wraps its screen content with +`CompositionLocalProvider(LocalOnboardingProgressBarAnimation provides viewModel.progressBarAnimation)`. +This keeps the inner screen composable signatures (and their previews) unchanged; only the +top-level destination composable and the `ViewModel` constructor change. + +Delivery has to happen inside the onboarding module because the holder is `internal`; `:app` +cannot reference it, so the established `metroViewModel()` injection path is the seam. This +touches the ~9 step ViewModels and their destinations, each a couple of lines. This is the +cost of the seamless result; it is the reason a self-contained per-step approach would be +cheaper but less crisp. + +### 3. Scaffold change + +`OnboardingStepScaffold` computes the target fraction and drives the shared holder: + +```kotlin +val target = progress?.let { (it.currentIndex + 1f) / it.totalSteps } +val animation = LocalOnboardingProgressBarAnimation.current +LaunchedEffect(target, animation) { + if (target != null) animation?.moveTo(target) +} +``` + +`(currentIndex + 1) / totalSteps` reproduces today's behaviour where welcome +(`currentIndex == 0`) already shows one filled segment out of `totalSteps`. + +`OnboardingProgressBar` renders a single rounded track instead of N discrete segments: +a `surfaceSecondary` background with a `fillPrimary` foreground bar whose width is the +current fraction of the track, clipped to `CircleShape`. The displayed fraction is +`animation?.fraction` when the holder is present, otherwise `target` directly (static, for +previews). No segment notches/dividers, for the cleanest progressing look. + +## Files touched + +- New: `OnboardingProgressBarAnimation` holder + `LocalOnboardingProgressBarAnimation` + (onboarding `ui` package). +- `OnboardingStepScaffold.kt`: read the local, drive the animation, render a continuous fill. +- Each step `ViewModel` that renders the scaffold: add the holder dependency + expose it. +- Each corresponding destination top-level composable: provide the local. + +## Non-goals + +- No change to how `OnboardingProgress` is computed or where progress comes from. +- No change to the shared-element header key or the `sharedBounds` wiring itself. +- No change to navigation, DI scopes, or the onboarding data layer. + +## Edge cases + +- Fresh launch / process-death restore onto a mid-path step: `seeded` snaps, no sweep from 0. +- Back navigation: animates the fill shrinking to the previous fraction. +- Isolated previews (no NavEntry, `null` local): static fill at `target`, no animation. From 2b5f74e721a95f92d306643becf865021625d470 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 29 Jul 2026 21:31:41 +0200 Subject: [PATCH 072/119] Animate onboarding progress bar fill across the step transition Drive the progress bar fill from a shared, onboarding-scoped holder that blends every on-screen step's fill by its transition presence, so the bar progresses continuously across the header's shared-element transition and tracks the predictive-back gesture instead of crossfading between states. --- .../ui/OnboardingProgressBarAnimation.kt | 57 +++++++++++++++ .../onboarding/ui/OnboardingStepScaffold.kt | 71 +++++++++++++++---- .../ui/bundle/OnboardingBundleDestination.kt | 3 + .../OnboardingCoInsuredDestination.kt | 3 + .../consent/OnboardingConsentDestination.kt | 3 + .../ui/invite/OnboardingInviteDestination.kt | 3 + .../payment/OnboardingPaymentDestination.kt | 3 + .../ui/petid/OnboardingPetIdDestination.kt | 3 + .../ui/phone/OnboardingPhoneDestination.kt | 3 + .../ui/theme/OnboardingThemeDestination.kt | 5 ++ .../welcome/OnboardingWelcomeDestination.kt | 3 + 11 files changed, 143 insertions(+), 14 deletions(-) create mode 100644 app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingProgressBarAnimation.kt diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingProgressBarAnimation.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingProgressBarAnimation.kt new file mode 100644 index 0000000000..4d3db5890f --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingProgressBarAnimation.kt @@ -0,0 +1,57 @@ +package com.hedvig.android.feature.onboarding.ui + +import androidx.compose.runtime.mutableStateMapOf +import com.hedvig.android.core.common.di.ActivityRetainedScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn + +/** + * Single source of truth for the onboarding progress bar's fill, shared by every step so the fill + * moves as one continuous value across the header's shared-element transition. + * + * Each step is a separate composition. While a transition runs, both the outgoing and incoming steps + * contribute their own target fill together with their transition presence (1 when fully on screen, + * 0 when gone). [displayedFraction] blends them by presence, so: + * - both bars render the identical value every frame, hiding the `sharedBounds` crossfade, + * - the fill follows the transition, including a predictive-back gesture and its seek, + * - a cancelled gesture returns smoothly as the presences return, with nothing left stranded, + * - a single step on screen simply shows its own fill, so fresh launches and process-death restores + * land on the right fill with no sweep. + * + * Scoped to the onboarding flow's lifetime, like + * [com.hedvig.android.feature.onboarding.data.OnboardingSessionStore]. + */ +@Inject +@SingleIn(ActivityRetainedScope::class) +internal class OnboardingProgressBarAnimation { + private val contributions = mutableStateMapOf() + + val displayedFraction: Float + get() { + var weighted = 0f + var totalPresence = 0f + for (contribution in contributions.values) { + weighted += contribution.fraction * contribution.presence + totalPresence += contribution.presence + } + if (totalPresence > PresenceEpsilon) return weighted / totalPresence + // No meaningful presence yet (e.g. the very first frame): fall back to the plain average so + // the bar never flashes empty. + if (contributions.isEmpty()) return 0f + return contributions.values.sumOf { it.fraction.toDouble() }.toFloat() / contributions.size + } + + fun contribute(key: Any, fraction: Float, presence: Float) { + contributions[key] = Contribution(fraction, presence) + } + + fun release(key: Any) { + contributions.remove(key) + } + + private data class Contribution(val fraction: Float, val presence: Float) + + private companion object { + const val PresenceEpsilon = 0.0001f + } +} diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt index 0dabaa65f3..8f9ab20fd1 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt @@ -1,6 +1,11 @@ package com.hedvig.android.feature.onboarding.ui +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.EnterExitState import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -22,6 +27,10 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -80,17 +89,18 @@ internal fun OnboardingStepScaffold( onBackClick: () -> Unit, onCloseClick: () -> Unit, modifier: Modifier = Modifier, + progressAnimation: OnboardingProgressBarAnimation? = null, content: @Composable ColumnScope.() -> Unit, ) { // Null only in isolated previews, where there is no NavEntry to read the animated-content scope - // from; the header then renders plainly. + // from; the header then renders plainly and the progress bar draws its fill statically. val sharedTransitionScope = LocalSharedTransitionScope.current - val headerModifier = if (sharedTransitionScope != null) { - val animatedContentScope = LocalNavAnimatedContentScope.current + val animatedVisibilityScope = if (sharedTransitionScope != null) LocalNavAnimatedContentScope.current else null + val headerModifier = if (sharedTransitionScope != null && animatedVisibilityScope != null) { with(sharedTransitionScope) { Modifier.sharedBounds( sharedContentState = rememberSharedContentState(OnboardingTopBarSharedKey), - animatedVisibilityScope = animatedContentScope, + animatedVisibilityScope = animatedVisibilityScope, ) } } else { @@ -131,6 +141,8 @@ internal fun OnboardingStepScaffold( if (progress != null) { OnboardingProgressBar( progress = progress, + animation = progressAnimation, + animatedVisibilityScope = animatedVisibilityScope, modifier = Modifier .weight(1f) .padding(horizontal = 24.dp), @@ -159,23 +171,54 @@ internal fun OnboardingStepScaffold( } @Composable -private fun OnboardingProgressBar(progress: OnboardingProgress, modifier: Modifier = Modifier) { +private fun OnboardingProgressBar( + progress: OnboardingProgress, + animation: OnboardingProgressBarAnimation?, + animatedVisibilityScope: AnimatedVisibilityScope?, + modifier: Modifier = Modifier, +) { + // currentIndex is 0-based with the welcome step at 0, so +1 keeps its first slice filled. + val target = ((progress.currentIndex + 1f) / progress.totalSteps).coerceIn(0f, 1f) + val fraction = if (animation != null && animatedVisibilityScope != null) { + // 1 while this step is fully on screen, 0 once it is gone; Nav3 seeks this with the + // predictive-back gesture, so the shared holder's blend follows the transition frame for frame. + val presence = animatedVisibilityScope.transition.animateFloat( + transitionSpec = { tween(durationMillis = 300, easing = FastOutSlowInEasing) }, + label = "onboardingProgressPresence", + ) { state -> if (state == EnterExitState.Visible) 1f else 0f } + val contributionKey = remember { Any() } + DisposableEffect(animation, contributionKey) { + onDispose { animation.release(contributionKey) } + } + LaunchedEffect(animation, contributionKey, target) { + snapshotFlow { presence.value }.collect { animation.contribute(contributionKey, target, it) } + } + animation.displayedFraction + } else { + target + } Row(horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier) { repeat(progress.totalSteps) { index -> - val isActivated = index <= progress.currentIndex + // Each slice fills in turn as the shared fraction crosses it, keeping the dividers visible + // while the fill flows continuously across them. + val sliceFraction = ((fraction * progress.totalSteps) - index).coerceIn(0f, 1f) Box( Modifier .weight(1f) .height(2.dp) .clip(CircleShape) - .background( - if (isActivated) { - HedvigTheme.colorScheme.fillPrimary - } else { - HedvigTheme.colorScheme.surfaceSecondary - }, - ), - ) + .background(HedvigTheme.colorScheme.surfaceSecondary), + ) { + if (sliceFraction > 0f) { + Box( + Modifier + .fillMaxWidth(sliceFraction) + .height(2.dp) + .clip(CircleShape) + .background(HedvigTheme.colorScheme.fillPrimary), + ) + } + } } } } diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt index 6e12b26ae4..59f5651124 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt @@ -37,6 +37,7 @@ import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId import com.hedvig.android.feature.onboarding.ui.OnboardingProgress +import com.hedvig.android.feature.onboarding.ui.OnboardingProgressBarAnimation import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons import com.hedvig.android.feature.onboarding.ui.OnboardingStepHeader import com.hedvig.android.feature.onboarding.ui.OnboardingStepScaffold @@ -57,6 +58,7 @@ import org.jetbrains.compose.resources.stringResource internal class OnboardingBundleViewModel( sessionStore: OnboardingSessionStore, navigator: OnboardingNavigator, + val progressBarAnimation: OnboardingProgressBarAnimation, ) : MoleculeViewModel( initialState = OnboardingBundleUiState.Loading, presenter = OnboardingBundlePresenter(sessionStore, navigator), @@ -131,6 +133,7 @@ internal fun OnboardingBundleDestination( showBackButton = true, onBackClick = navigateUp, onCloseClick = { viewModel.emit(OnboardingBundleEvent.Close) }, + progressAnimation = viewModel.progressBarAnimation, ) { when (val state = uiState) { OnboardingBundleUiState.Loading -> { diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt index 9e1d9d6194..3af2fef81e 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt @@ -31,6 +31,7 @@ import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId import com.hedvig.android.feature.onboarding.ui.OnboardingContractCard import com.hedvig.android.feature.onboarding.ui.OnboardingProgress +import com.hedvig.android.feature.onboarding.ui.OnboardingProgressBarAnimation import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons import com.hedvig.android.feature.onboarding.ui.OnboardingStepHeader import com.hedvig.android.feature.onboarding.ui.OnboardingStepScaffold @@ -53,6 +54,7 @@ import org.jetbrains.compose.resources.stringResource internal class OnboardingCoInsuredViewModel( sessionStore: OnboardingSessionStore, navigator: OnboardingNavigator, + val progressBarAnimation: OnboardingProgressBarAnimation, ) : MoleculeViewModel( initialState = OnboardingCoInsuredUiState.Loading, presenter = OnboardingCoInsuredPresenter(sessionStore, navigator), @@ -171,6 +173,7 @@ internal fun OnboardingCoInsuredDestination(viewModel: OnboardingCoInsuredViewMo showBackButton = true, onBackClick = navigateUp, onCloseClick = { viewModel.emit(OnboardingCoInsuredEvent.Close) }, + progressAnimation = viewModel.progressBarAnimation, ) { when (val content = uiState) { OnboardingCoInsuredUiState.Loading -> { diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingConsentDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingConsentDestination.kt index 79500af6a6..08452c1ac6 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingConsentDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingConsentDestination.kt @@ -43,6 +43,7 @@ import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId import com.hedvig.android.feature.onboarding.ui.OnboardingProgress +import com.hedvig.android.feature.onboarding.ui.OnboardingProgressBarAnimation import com.hedvig.android.feature.onboarding.ui.OnboardingStepHeader import com.hedvig.android.feature.onboarding.ui.OnboardingStepScaffold import com.hedvig.android.feature.onboarding.ui.progressFor @@ -65,6 +66,7 @@ internal class OnboardingConsentViewModel( sessionStore: OnboardingSessionStore, navigator: OnboardingNavigator, settingsDataStore: SettingsDataStore, + val progressBarAnimation: OnboardingProgressBarAnimation, ) : MoleculeViewModel( initialState = OnboardingConsentUiState.Loading, presenter = OnboardingConsentPresenter(sessionStore, navigator, settingsDataStore), @@ -145,6 +147,7 @@ internal fun OnboardingConsentDestination( showBackButton = true, onBackClick = navigateUp, onCloseClick = { viewModel.emit(OnboardingConsentEvent.Close) }, + progressAnimation = viewModel.progressBarAnimation, ) { when (uiState) { OnboardingConsentUiState.Loading -> { diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt index cee8f87c0a..dcca5b4836 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt @@ -35,6 +35,7 @@ import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId import com.hedvig.android.feature.onboarding.ui.OnboardingProgress +import com.hedvig.android.feature.onboarding.ui.OnboardingProgressBarAnimation import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons import com.hedvig.android.feature.onboarding.ui.OnboardingStepHeader import com.hedvig.android.feature.onboarding.ui.OnboardingStepScaffold @@ -55,6 +56,7 @@ import org.jetbrains.compose.resources.stringResource internal class OnboardingInviteViewModel( sessionStore: OnboardingSessionStore, navigator: OnboardingNavigator, + val progressBarAnimation: OnboardingProgressBarAnimation, ) : MoleculeViewModel( initialState = OnboardingInviteUiState.Loading, presenter = OnboardingInvitePresenter(sessionStore, navigator), @@ -133,6 +135,7 @@ internal fun OnboardingInviteDestination(viewModel: OnboardingInviteViewModel, n showBackButton = true, onBackClick = navigateUp, onCloseClick = { viewModel.emit(OnboardingInviteEvent.Close) }, + progressAnimation = viewModel.progressBarAnimation, ) { when (val state = uiState) { OnboardingInviteUiState.Loading -> { diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt index 09fad76730..fa762e7fe6 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt @@ -27,6 +27,7 @@ import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId import com.hedvig.android.feature.onboarding.ui.OnboardingProgress +import com.hedvig.android.feature.onboarding.ui.OnboardingProgressBarAnimation import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons import com.hedvig.android.feature.onboarding.ui.OnboardingStepHeader import com.hedvig.android.feature.onboarding.ui.OnboardingStepScaffold @@ -48,6 +49,7 @@ import org.jetbrains.compose.resources.stringResource internal class OnboardingPaymentViewModel( sessionStore: OnboardingSessionStore, navigator: OnboardingNavigator, + val progressBarAnimation: OnboardingProgressBarAnimation, ) : MoleculeViewModel( initialState = OnboardingPaymentUiState.Loading, presenter = OnboardingPaymentPresenter(sessionStore, navigator), @@ -145,6 +147,7 @@ internal fun OnboardingPaymentDestination(viewModel: OnboardingPaymentViewModel, showBackButton = true, onBackClick = navigateUp, onCloseClick = { viewModel.emit(OnboardingPaymentEvent.Close) }, + progressAnimation = viewModel.progressBarAnimation, ) { when (val content = uiState) { OnboardingPaymentUiState.Loading -> { diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdDestination.kt index a795c8ac53..ffbdad1bf4 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdDestination.kt @@ -31,6 +31,7 @@ import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId import com.hedvig.android.feature.onboarding.ui.OnboardingContractCard import com.hedvig.android.feature.onboarding.ui.OnboardingProgress +import com.hedvig.android.feature.onboarding.ui.OnboardingProgressBarAnimation import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons import com.hedvig.android.feature.onboarding.ui.OnboardingStepHeader import com.hedvig.android.feature.onboarding.ui.OnboardingStepScaffold @@ -53,6 +54,7 @@ import org.jetbrains.compose.resources.stringResource internal class OnboardingPetIdViewModel( sessionStore: OnboardingSessionStore, navigator: OnboardingNavigator, + val progressBarAnimation: OnboardingProgressBarAnimation, ) : MoleculeViewModel( initialState = OnboardingPetIdUiState.Loading, presenter = OnboardingPetIdPresenter(sessionStore, navigator), @@ -171,6 +173,7 @@ internal fun OnboardingPetIdDestination( showBackButton = true, onBackClick = navigateUp, onCloseClick = { viewModel.emit(OnboardingPetIdEvent.Close) }, + progressAnimation = viewModel.progressBarAnimation, ) { when (val content = uiState) { OnboardingPetIdUiState.Loading -> { diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt index b832a2e32d..91b716485f 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt @@ -30,6 +30,7 @@ import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId import com.hedvig.android.feature.onboarding.ui.OnboardingProgress +import com.hedvig.android.feature.onboarding.ui.OnboardingProgressBarAnimation import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons import com.hedvig.android.feature.onboarding.ui.OnboardingStepHeader import com.hedvig.android.feature.onboarding.ui.OnboardingStepScaffold @@ -53,6 +54,7 @@ internal class OnboardingPhoneViewModel( sessionStore: OnboardingSessionStore, navigator: OnboardingNavigator, onboardingRepository: OnboardingRepository, + val progressBarAnimation: OnboardingProgressBarAnimation, ) : MoleculeViewModel( initialState = OnboardingPhoneUiState.Loading, presenter = OnboardingPhonePresenter(sessionStore, navigator, onboardingRepository), @@ -170,6 +172,7 @@ internal fun OnboardingPhoneDestination(viewModel: OnboardingPhoneViewModel, nav showBackButton = true, onBackClick = navigateUp, onCloseClick = { viewModel.emit(OnboardingPhoneEvent.Close) }, + progressAnimation = viewModel.progressBarAnimation, ) { when (val content = uiState) { OnboardingPhoneUiState.Loading -> { diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt index 83b40a7f1a..e7d54d78a8 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt @@ -46,6 +46,7 @@ import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId import com.hedvig.android.feature.onboarding.ui.OnboardingProgress +import com.hedvig.android.feature.onboarding.ui.OnboardingProgressBarAnimation import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons import com.hedvig.android.feature.onboarding.ui.OnboardingStepHeader import com.hedvig.android.feature.onboarding.ui.OnboardingStepScaffold @@ -75,6 +76,7 @@ internal class OnboardingThemeViewModel( sessionStore: OnboardingSessionStore, navigator: OnboardingNavigator, settingsDataStore: SettingsDataStore, + val progressBarAnimation: OnboardingProgressBarAnimation, ) : MoleculeViewModel( initialState = OnboardingThemeUiState.Loading, presenter = OnboardingThemePresenter(sessionStore, navigator, settingsDataStore), @@ -159,6 +161,7 @@ internal fun OnboardingThemeDestination(viewModel: OnboardingThemeViewModel, nav onRetry = { viewModel.emit(OnboardingThemeEvent.Retry) }, onSelectTheme = { viewModel.emit(OnboardingThemeEvent.SelectTheme(it)) }, onContinueClick = { viewModel.emit(OnboardingThemeEvent.Continue) }, + progressAnimation = viewModel.progressBarAnimation, ) } @@ -170,12 +173,14 @@ private fun OnboardingThemeDestination( onRetry: () -> Unit, onSelectTheme: (Theme) -> Unit, onContinueClick: () -> Unit, + progressAnimation: OnboardingProgressBarAnimation? = null, ) { OnboardingStepScaffold( progress = (uiState as? OnboardingThemeUiState.Content)?.progress, showBackButton = true, onBackClick = navigateUp, onCloseClick = onCloseClick, + progressAnimation = progressAnimation, ) { when (uiState) { OnboardingThemeUiState.Loading -> { diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt index 5381156f01..55e37924be 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt @@ -28,6 +28,7 @@ import com.hedvig.android.design.system.hedvig.LocalTextStyle import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator import com.hedvig.android.feature.onboarding.ui.OnboardingProgress +import com.hedvig.android.feature.onboarding.ui.OnboardingProgressBarAnimation import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons import com.hedvig.android.feature.onboarding.ui.OnboardingStepScaffold import com.hedvig.android.feature.onboarding.ui.progressFor @@ -47,6 +48,7 @@ import org.jetbrains.compose.resources.stringResource internal class OnboardingWelcomeViewModel( sessionStore: OnboardingSessionStore, navigator: OnboardingNavigator, + val progressBarAnimation: OnboardingProgressBarAnimation, ) : MoleculeViewModel( initialState = OnboardingWelcomeUiState.Loading, presenter = OnboardingWelcomePresenter(sessionStore, navigator), @@ -110,6 +112,7 @@ internal fun OnboardingWelcomeDestination(viewModel: OnboardingWelcomeViewModel) showBackButton = false, onBackClick = {}, onCloseClick = { viewModel.emit(OnboardingWelcomeEvent.Close) }, + progressAnimation = viewModel.progressBarAnimation, ) { when (uiState) { OnboardingWelcomeUiState.Loading -> { From bc73b56464522dc96595a240a82501073707f59e Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 29 Jul 2026 22:06:19 +0200 Subject: [PATCH 073/119] Express onboarding progress bar fill in step-number units Rename the shared holder's internals from a 0-1 fraction blend to a visibility-weighted average of step numbers (filledStepCount), and drop the fraction/totalSteps conversions in the bar renderer. No behaviour change; the fill still tracks the transition and predictive-back gesture. --- .../ui/OnboardingProgressBarAnimation.kt | 51 +++--- .../onboarding/ui/OnboardingStepScaffold.kt | 39 ++--- ...oarding-progress-bar-progression-design.md | 159 ++++++++++-------- 3 files changed, 138 insertions(+), 111 deletions(-) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingProgressBarAnimation.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingProgressBarAnimation.kt index 4d3db5890f..5d92f67212 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingProgressBarAnimation.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingProgressBarAnimation.kt @@ -10,12 +10,13 @@ import dev.zacsweers.metro.SingleIn * moves as one continuous value across the header's shared-element transition. * * Each step is a separate composition. While a transition runs, both the outgoing and incoming steps - * contribute their own target fill together with their transition presence (1 when fully on screen, - * 0 when gone). [displayedFraction] blends them by presence, so: + * register themselves here with their step number and how visible each one currently is (1 when fully + * on screen, 0 when gone). [filledStepCount] averages the step numbers by visibility, so: * - both bars render the identical value every frame, hiding the `sharedBounds` crossfade, - * - the fill follows the transition, including a predictive-back gesture and its seek, - * - a cancelled gesture returns smoothly as the presences return, with nothing left stranded, - * - a single step on screen simply shows its own fill, so fresh launches and process-death restores + * - the value sits between the outgoing and incoming steps and follows the transition, including a + * predictive-back gesture and its seek, + * - a cancelled gesture returns smoothly as the visibilities return, with nothing left stranded, + * - a single step on screen simply shows its own number, so fresh launches and process-death restores * land on the right fill with no sweep. * * Scoped to the onboarding flow's lifetime, like @@ -24,34 +25,40 @@ import dev.zacsweers.metro.SingleIn @Inject @SingleIn(ActivityRetainedScope::class) internal class OnboardingProgressBarAnimation { - private val contributions = mutableStateMapOf() + private val visibleSteps = mutableStateMapOf() - val displayedFraction: Float + /** + * How many steps' worth of the bar to fill, possibly fractional. It is the [VisibleStep.stepNumber] + * of every on-screen step averaged by how visible each one is, so during a transition it sits + * between the outgoing and incoming steps (2.7 means "70% of the way from step 2 to step 3"). With + * a single step on screen it is simply that step's number. + */ + val filledStepCount: Float get() { - var weighted = 0f - var totalPresence = 0f - for (contribution in contributions.values) { - weighted += contribution.fraction * contribution.presence - totalPresence += contribution.presence + var stepNumberSum = 0f + var visibleSum = 0f + for (step in visibleSteps.values) { + stepNumberSum += step.stepNumber * step.visibleAmount + visibleSum += step.visibleAmount } - if (totalPresence > PresenceEpsilon) return weighted / totalPresence - // No meaningful presence yet (e.g. the very first frame): fall back to the plain average so + if (visibleSum > MinimumVisible) return stepNumberSum / visibleSum + // Nothing is really visible yet (e.g. the very first frame): fall back to the plain average so // the bar never flashes empty. - if (contributions.isEmpty()) return 0f - return contributions.values.sumOf { it.fraction.toDouble() }.toFloat() / contributions.size + if (visibleSteps.isEmpty()) return 0f + return visibleSteps.values.sumOf { it.stepNumber.toDouble() }.toFloat() / visibleSteps.size } - fun contribute(key: Any, fraction: Float, presence: Float) { - contributions[key] = Contribution(fraction, presence) + fun setVisibleStep(key: Any, stepNumber: Int, visibleAmount: Float) { + visibleSteps[key] = VisibleStep(stepNumber, visibleAmount) } - fun release(key: Any) { - contributions.remove(key) + fun removeStep(key: Any) { + visibleSteps.remove(key) } - private data class Contribution(val fraction: Float, val presence: Float) + private data class VisibleStep(val stepNumber: Int, val visibleAmount: Float) private companion object { - const val PresenceEpsilon = 0.0001f + const val MinimumVisible = 0.0001f } } diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt index 8f9ab20fd1..c41a42c301 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt @@ -177,31 +177,32 @@ private fun OnboardingProgressBar( animatedVisibilityScope: AnimatedVisibilityScope?, modifier: Modifier = Modifier, ) { - // currentIndex is 0-based with the welcome step at 0, so +1 keeps its first slice filled. - val target = ((progress.currentIndex + 1f) / progress.totalSteps).coerceIn(0f, 1f) - val fraction = if (animation != null && animatedVisibilityScope != null) { - // 1 while this step is fully on screen, 0 once it is gone; Nav3 seeks this with the - // predictive-back gesture, so the shared holder's blend follows the transition frame for frame. - val presence = animatedVisibilityScope.transition.animateFloat( + // currentIndex is 0-based with welcome at 0, so this step is human step number currentIndex + 1. + val stepNumber = progress.currentIndex + 1 + val filledStepCount = if (animation != null && animatedVisibilityScope != null) { + // 1 while this step is fully on screen, 0 once it is gone; Nav3 moves this with the transition, + // including a predictive-back gesture, so the shared bar follows the gesture frame for frame. + val visibleAmount = animatedVisibilityScope.transition.animateFloat( transitionSpec = { tween(durationMillis = 300, easing = FastOutSlowInEasing) }, - label = "onboardingProgressPresence", + label = "onboardingStepVisibleAmount", ) { state -> if (state == EnterExitState.Visible) 1f else 0f } - val contributionKey = remember { Any() } - DisposableEffect(animation, contributionKey) { - onDispose { animation.release(contributionKey) } + val key = remember { Any() } + DisposableEffect(animation, key) { + onDispose { animation.removeStep(key) } } - LaunchedEffect(animation, contributionKey, target) { - snapshotFlow { presence.value }.collect { animation.contribute(contributionKey, target, it) } + LaunchedEffect(animation, key, stepNumber) { + snapshotFlow { visibleAmount.value }.collect { animation.setVisibleStep(key, stepNumber, it) } } - animation.displayedFraction + animation.filledStepCount } else { - target + // Isolated preview: no transition, so simply fill up to this step. + stepNumber.toFloat() } Row(horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier) { repeat(progress.totalSteps) { index -> - // Each slice fills in turn as the shared fraction crosses it, keeping the dividers visible - // while the fill flows continuously across them. - val sliceFraction = ((fraction * progress.totalSteps) - index).coerceIn(0f, 1f) + // Slice `index` fills as filledStepCount passes it: full once the count reaches the next whole + // number, partially while it is crossing this slice. Dividers stay visible between slices. + val sliceFill = (filledStepCount - index).coerceIn(0f, 1f) Box( Modifier .weight(1f) @@ -209,10 +210,10 @@ private fun OnboardingProgressBar( .clip(CircleShape) .background(HedvigTheme.colorScheme.surfaceSecondary), ) { - if (sliceFraction > 0f) { + if (sliceFill > 0f) { Box( Modifier - .fillMaxWidth(sliceFraction) + .fillMaxWidth(sliceFill) .height(2.dp) .clip(CircleShape) .background(HedvigTheme.colorScheme.fillPrimary), diff --git a/docs/superpowers/specs/2026-07-29-onboarding-progress-bar-progression-design.md b/docs/superpowers/specs/2026-07-29-onboarding-progress-bar-progression-design.md index 505881ae5c..6728bdbe5c 100644 --- a/docs/superpowers/specs/2026-07-29-onboarding-progress-bar-progression-design.md +++ b/docs/superpowers/specs/2026-07-29-onboarding-progress-bar-progression-design.md @@ -6,110 +6,127 @@ Each onboarding step is a separate `NavEntry`, i.e. a separate composition (`OnboardingEntries.kt`). The header `Row` in `OnboardingStepScaffold` is a single shared element (`sharedBounds`, key `"onboarding-top-bar"`) pinned across steps. During a step transition both the outgoing and incoming headers are composed at the same bounds and -`sharedBounds` crossfades their content. Because the two `OnboardingProgressBar`s differ -only in how many segments are filled, the user sees the old fill dissolve into the new one -instead of a fill growing left-to-right. +`sharedBounds` crossfades their content. Because the two progress bars differ only in how +many segments are filled, the user sees the old fill dissolve into the new one instead of a +fill moving across. ## Goal -Make the progress bar look like it is genuinely progressing left-to-right as the shared -transition runs: a single continuous fill that grows (or shrinks, on back navigation) from -the previous step's fraction to the current step's fraction, seamlessly, with no visible -crossfade between two different states. +The bar should look like one continuous fill that moves from the previous step to the current +step as the transition runs, with the dividers between segments still visible. It must track a +predictive-back gesture frame for frame and return smoothly if the gesture is cancelled, rather +than jumping or getting stranded. ## Key insight -The fill must be driven by a value that is **the same instance in both compositions and -animates across the transition**. If the outgoing and incoming bars both read the identical -value every frame, the `sharedBounds` crossfade becomes invisible (both sides look the same) -while the value animates, producing a clean growing fill. +The fill must be driven by a value that is **the same in both compositions and moves with the +transition**. If the outgoing and incoming bars render the identical value every frame, the +`sharedBounds` crossfade becomes invisible (both sides look the same) while the value moves. + +Compose already exposes a value that moves with the transition, including predictive-back seek: +`animatedVisibilityScope.transition.animateFloat { Visible -> 1f else 0f }`. This is each step's +"how visible am I" (1 on screen, 0 gone), and Nav3 seeks it with the gesture. Driving the fill +from this, rather than a self-timed animation, is what makes it track and cancel correctly. ## Design -### 1. Shared, flow-scoped animation holder (UI layer) +### 1. Shared, flow-scoped holder (UI layer) -A new `internal` class in the onboarding `ui` package, scoped to the onboarding flow's -lifetime (matching `OnboardingSessionStore`, which is `@SingleIn(ActivityRetainedScope::class)` -and survives across every step within one Activity): +A new `internal` class in the onboarding `ui` package, scoped to the onboarding flow's lifetime +(matching `OnboardingSessionStore`, `@SingleIn(ActivityRetainedScope::class)`): ```kotlin @Inject @SingleIn(ActivityRetainedScope::class) internal class OnboardingProgressBarAnimation { - private val animatable = Animatable(0f) - private var seeded = false + private val visibleSteps = mutableStateMapOf() + + val filledStepCount: Float + get() { + var stepNumberSum = 0f + var visibleSum = 0f + for (step in visibleSteps.values) { + stepNumberSum += step.stepNumber * step.visibleAmount + visibleSum += step.visibleAmount + } + if (visibleSum > MinimumVisible) return stepNumberSum / visibleSum + if (visibleSteps.isEmpty()) return 0f + return visibleSteps.values.sumOf { it.stepNumber.toDouble() }.toFloat() / visibleSteps.size + } - val fraction: Float get() = animatable.value + fun setVisibleStep(key: Any, stepNumber: Int, visibleAmount: Float) { visibleSteps[key] = VisibleStep(stepNumber, visibleAmount) } + fun removeStep(key: Any) { visibleSteps.remove(key) } - suspend fun moveTo(target: Float) { - if (!seeded) { - seeded = true - animatable.snapTo(target) - } else { - animatable.animateTo(target) - } - } + private data class VisibleStep(val stepNumber: Int, val visibleAmount: Float) + private companion object { const val MinimumVisible = 0.0001f } } ``` -One `Animatable`, one instance for the whole flow. `fraction` is snapshot-backed, so both -the outgoing and incoming step recompose in lockstep as it animates. +Every step currently on screen registers itself with its **step number** (`currentIndex + 1`, +1-based) and its **visible amount** (0–1). `filledStepCount` is the step numbers averaged by +visibility, a possibly-fractional step count. Because it is a weighted average it always lands +between the smallest and largest on-screen step number, so it never runs away as step numbers +grow. The map only ever holds the on-screen steps (one at rest, two during a transition; each +step removes itself on dispose). -The `seeded` flag handles timing edge cases: the first value the holder ever sees snaps -(fresh flow launch, or process-death restore landing directly on a mid-path step), while -every later step-to-step transition animates from the current fill. Back navigation animates -the fill shrinking left, the natural inverse. +Why this shape rather than a single `Animatable.animateTo(target)`: a self-timed animation runs +on its own clock, so it jumps instead of tracking the gesture, and a cancelled predictive-back +gesture leaves it stranded with nothing to drive it back. A visibility-weighted average is a pure +function of the seekable transition state, so it tracks and cancels for free. A single on-screen +step averages to its own number, so fresh launches and process-death restores need no seeding. -### 2. Delivery via CompositionLocal +### 2. Delivery -A `CompositionLocal` defaulting to `null` (mirroring how the scaffold already reads -`LocalSharedTransitionScope` and tolerates `null` in isolated previews): +`OnboardingStepScaffold` takes a nullable `progressAnimation: OnboardingProgressBarAnimation?` +(null in isolated previews, mirroring how it already tolerates `LocalSharedTransitionScope == null`). +Each step's `ViewModel` gains the holder as a constructor dependency and exposes it as a plain +property; each destination passes `viewModel.progressBarAnimation` into the scaffold. -```kotlin -internal val LocalOnboardingProgressBarAnimation = - staticCompositionLocalOf { null } -``` - -Each step's `ViewModel` gains the holder as a constructor dependency and exposes it as a -plain property. Each destination's top-level composable wraps its screen content with -`CompositionLocalProvider(LocalOnboardingProgressBarAnimation provides viewModel.progressBarAnimation)`. -This keeps the inner screen composable signatures (and their previews) unchanged; only the -top-level destination composable and the `ViewModel` constructor change. - -Delivery has to happen inside the onboarding module because the holder is `internal`; `:app` -cannot reference it, so the established `metroViewModel()` injection path is the seam. This -touches the ~9 step ViewModels and their destinations, each a couple of lines. This is the -cost of the seamless result; it is the reason a self-contained per-step approach would be -cheaper but less crisp. +Delivery has to happen inside the onboarding module because the holder is `internal` and each step +is a separate `NavEntry` with no shared parent composition, so the established `metroViewModel()` +injection path is the only seam. A `CompositionLocal` would not avoid this (it would still need a +per-entry instance to provide), so an explicit param is the simpler, more honest choice. This +touches the ~9 step ViewModels and their destinations, a couple of lines each. ### 3. Scaffold change -`OnboardingStepScaffold` computes the target fraction and drives the shared holder: +Inside `OnboardingStepScaffold`, `animatedVisibilityScope` is read once +(`LocalNavAnimatedContentScope.current` when a shared-transition scope is present, else null) and +passed to `OnboardingProgressBar` alongside the holder. The bar: ```kotlin -val target = progress?.let { (it.currentIndex + 1f) / it.totalSteps } -val animation = LocalOnboardingProgressBarAnimation.current -LaunchedEffect(target, animation) { - if (target != null) animation?.moveTo(target) +val stepNumber = progress.currentIndex + 1 +val filledStepCount = if (animation != null && animatedVisibilityScope != null) { + val visibleAmount = animatedVisibilityScope.transition.animateFloat( + transitionSpec = { tween(durationMillis = 300, easing = FastOutSlowInEasing) }, + label = "onboardingStepVisibleAmount", + ) { state -> if (state == EnterExitState.Visible) 1f else 0f } + val key = remember { Any() } + DisposableEffect(animation, key) { onDispose { animation.removeStep(key) } } + LaunchedEffect(animation, key, stepNumber) { + snapshotFlow { visibleAmount.value }.collect { animation.setVisibleStep(key, stepNumber, it) } + } + animation.filledStepCount +} else { + stepNumber.toFloat() } ``` -`(currentIndex + 1) / totalSteps` reproduces today's behaviour where welcome -(`currentIndex == 0`) already shows one filled segment out of `totalSteps`. +The bar is a `Row` of `totalSteps` slices (`Arrangement.spacedBy(4.dp)` keeps the dividers). Slice +`index` fills by `(filledStepCount - index).coerceIn(0f, 1f)`, so slices fill in turn and the fill +flows continuously across the dividers. At rest `filledStepCount == stepNumber`, reproducing the +original discrete behaviour (welcome shows one filled slice). -`OnboardingProgressBar` renders a single rounded track instead of N discrete segments: -a `surfaceSecondary` background with a `fillPrimary` foreground bar whose width is the -current fraction of the track, clipped to `CircleShape`. The displayed fraction is -`animation?.fraction` when the holder is present, otherwise `target` directly (static, for -previews). No segment notches/dividers, for the cleanest progressing look. +The presence tween is `300ms` to match the nav `sharedXAxis` transition (`DurationMedium2`); during +a predictive-back seek it is driven by the gesture regardless of the tween. ## Files touched -- New: `OnboardingProgressBarAnimation` holder + `LocalOnboardingProgressBarAnimation` - (onboarding `ui` package). -- `OnboardingStepScaffold.kt`: read the local, drive the animation, render a continuous fill. +- New: `OnboardingProgressBarAnimation` holder (onboarding `ui` package). +- `OnboardingStepScaffold.kt`: read the animated-visibility scope, drive/read the holder, render + the segmented continuous fill. - Each step `ViewModel` that renders the scaffold: add the holder dependency + expose it. -- Each corresponding destination top-level composable: provide the local. +- Each corresponding destination: pass `viewModel.progressBarAnimation` into the scaffold. ## Non-goals @@ -119,6 +136,8 @@ previews). No segment notches/dividers, for the cleanest progressing look. ## Edge cases -- Fresh launch / process-death restore onto a mid-path step: `seeded` snaps, no sweep from 0. -- Back navigation: animates the fill shrinking to the previous fraction. -- Isolated previews (no NavEntry, `null` local): static fill at `target`, no animation. +- Fresh launch / process-death restore onto a mid-path step: one on-screen step, so the average is + just its own number, no sweep from 0. +- Back navigation and predictive-back seek: the average moves between the two on-screen steps as + their visibilities trade off; a cancelled gesture returns smoothly as the visibilities return. +- Isolated previews (no NavEntry, null holder/scope): the bar fills statically up to `stepNumber`. From dc60b17bb333a57515d046e8de48bbf65dc4d64d Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Fri, 31 Jul 2026 16:58:02 +0200 Subject: [PATCH 074/119] Show opens-in-browser hint on onboarding bundle step --- .../ui/bundle/OnboardingBundleDestination.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt index 59f5651124..23c09c995f 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt @@ -19,6 +19,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.ImageLoader @@ -49,6 +50,7 @@ import dev.zacsweers.metro.Inject import hedvig.resources.ONBOARDING_CONTINUE_TO_APP_BUTTON import hedvig.resources.ONBOARDING_CROSS_SELL_SUBTITLE import hedvig.resources.ONBOARDING_CROSS_SELL_TITLE +import hedvig.resources.ONBOARDING_OPENS_IN_BROWSER_HINT import hedvig.resources.Res import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource @@ -164,6 +166,16 @@ internal fun OnboardingBundleDestination( openUrl = openUrl, ) } + Spacer(Modifier.height(8.dp)) + HedvigText( + text = stringResource(Res.string.ONBOARDING_OPENS_IN_BROWSER_HINT), + style = HedvigTheme.typography.finePrint, + color = HedvigTheme.colorScheme.textSecondary, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) Spacer(Modifier.weight(1f)) Spacer(Modifier.height(24.dp)) OnboardingStepButtons( From a41f0651052a3ec75bc6a62fedacefe4d555ce89 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Mon, 3 Aug 2026 14:16:34 +0200 Subject: [PATCH 075/119] Detect co-owners vs co-insured in onboarding and drop invented payment copy --- .../src/main/graphql/OnboardingQuery.graphql | 7 ++++ .../feature/onboarding/data/OnboardingData.kt | 20 +++++++++-- .../feature/onboarding/data/OnboardingPath.kt | 2 +- .../onboarding/data/OnboardingRepository.kt | 13 ++++++- .../navigation/OnboardingNavigator.kt | 6 ++-- .../onboarding/ui/OnboardingStepScaffold.kt | 8 +++-- .../OnboardingCoInsuredDestination.kt | 36 +++++++++++++------ .../payment/OnboardingPaymentDestination.kt | 28 +++------------ .../onboarding/data/OnboardingPathTest.kt | 8 +++++ .../OnboardingCoInsuredPresenterTest.kt | 35 +++++++++++++++++- 10 files changed, 117 insertions(+), 46 deletions(-) diff --git a/app/feature/feature-onboarding/src/main/graphql/OnboardingQuery.graphql b/app/feature/feature-onboarding/src/main/graphql/OnboardingQuery.graphql index aac5807757..893a063104 100644 --- a/app/feature/feature-onboarding/src/main/graphql/OnboardingQuery.graphql +++ b/app/feature/feature-onboarding/src/main/graphql/OnboardingQuery.graphql @@ -7,8 +7,15 @@ query Onboarding { id exposureDisplayNameShort isMissingPetId + supportsCoInsured + supportsCoOwners coInsured { hasMissingInfo + terminatesOn + } + coOwners { + hasMissingInfo + terminatesOn } currentAgreement { productVariant { diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingData.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingData.kt index 48639e9420..ce8d640a4a 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingData.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingData.kt @@ -1,5 +1,7 @@ package com.hedvig.android.feature.onboarding.data +import com.hedvig.android.data.coinsured.CoInsuredFlowType + /** * Everything the onboarding flow needs, fetched eagerly in one round trip before the flow is * shown, so the progress bar length is known up-front and no step blocks on network to render. @@ -12,8 +14,9 @@ internal data class OnboardingData( val payinStatus: OnboardingPayinStatus, val crossSells: List, ) { - val contractsWithMissingCoInsured: List = - contracts.filter { it.missingCoInsuredCount > 0 } + /** Contracts missing co-insured OR co-owner info, i.e. anything the co-insured step should show. */ + val contractsMissingInsuredOrOwnerInfo: List = + contracts.filter { it.coInsuredFlowType != null } val contractsWithMissingPetId: List = contracts.filter { it.isMissingPetId } @@ -43,7 +46,18 @@ internal data class OnboardingContract( val typeOfContract: String, val missingCoInsuredCount: Int, val isMissingPetId: Boolean, -) + val missingCoOwnersCount: Int = 0, +) { + /** + * Which edit flow this contract's missing info needs, or null when nothing is missing. Co-owners + * takes precedence, mirroring the home-screen "needs co-insured info" reminder rule. + */ + val coInsuredFlowType: CoInsuredFlowType? = when { + missingCoOwnersCount > 0 -> CoInsuredFlowType.CoOwners + missingCoInsuredCount > 0 -> CoInsuredFlowType.CoInsured + else -> null + } +} internal data class OnboardingReferralInformation( val code: String, diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingPath.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingPath.kt index 570f953404..d726f7ac88 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingPath.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingPath.kt @@ -11,7 +11,7 @@ internal fun buildOnboardingPath(data: OnboardingData): List = add(OnboardingStepId.AnalyticsConsent) add(OnboardingStepId.PhoneNumber) add(OnboardingStepId.Theme) - if (data.contractsWithMissingCoInsured.isNotEmpty()) { + if (data.contractsMissingInsuredOrOwnerInfo.isNotEmpty()) { add(OnboardingStepId.CoInsured) } if (data.contractsWithMissingPetId.isNotEmpty()) { diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingRepository.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingRepository.kt index eb69c49284..17425757ed 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingRepository.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingRepository.kt @@ -42,7 +42,18 @@ internal class OnboardingRepositoryImpl( displayName = contract.currentAgreement.productVariant.displayName, exposureName = contract.exposureDisplayNameShort, typeOfContract = contract.currentAgreement.productVariant.typeOfContract, - missingCoInsuredCount = contract.coInsured.orEmpty().count { it.hasMissingInfo }, + // Mirror the home-screen reminder rule: only count still-active (not terminating) entries + // whose type the contract actually supports. + missingCoInsuredCount = if (contract.supportsCoInsured) { + contract.coInsured.orEmpty().count { it.hasMissingInfo && it.terminatesOn == null } + } else { + 0 + }, + missingCoOwnersCount = if (contract.supportsCoOwners) { + contract.coOwners.orEmpty().count { it.hasMissingInfo && it.terminatesOn == null } + } else { + 0 + }, isMissingPetId = contract.isMissingPetId, ) }, diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingNavigator.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingNavigator.kt index 29c13bf33d..0069840000 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingNavigator.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingNavigator.kt @@ -52,9 +52,9 @@ internal class OnboardingNavigator( backstack.removeAllOf() } - /** Pushes the existing edit-co-insured flow; it pops itself back here when done. */ - fun openAddCoInsured(contractId: String) { - backstack.add(CoInsuredAddInfoKey(contractId, CoInsuredFlowType.CoInsured)) + /** Pushes the existing edit-co-insured (or co-owners) flow; it pops itself back here when done. */ + fun openAddCoInsured(contractId: String, type: CoInsuredFlowType) { + backstack.add(CoInsuredAddInfoKey(contractId, type)) } /** Pushes the Trustly connect-payment flow; it pops itself back here when done. */ diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt index c41a42c301..bf15e388f6 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt @@ -225,11 +225,13 @@ private fun OnboardingProgressBar( } @Composable -internal fun OnboardingStepHeader(title: String, description: String, modifier: Modifier = Modifier) { +internal fun OnboardingStepHeader(title: String, modifier: Modifier = Modifier, description: String? = null) { Column(modifier.padding(horizontal = 16.dp)) { HedvigText(text = title) - Spacer(Modifier.height(4.dp)) - HedvigText(text = description, color = HedvigTheme.colorScheme.textSecondary) + if (description != null) { + Spacer(Modifier.height(4.dp)) + HedvigText(text = description, color = HedvigTheme.colorScheme.textSecondary) + } } } diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt index 3af2fef81e..d4e6794466 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt @@ -21,6 +21,7 @@ import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.hedvig.android.core.common.di.ActivityRetainedScope import com.hedvig.android.core.common.di.HedvigViewModel +import com.hedvig.android.data.coinsured.CoInsuredFlowType import com.hedvig.android.design.system.hedvig.HedvigErrorSection import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgressDebounced import com.hedvig.android.design.system.hedvig.HedvigText @@ -41,6 +42,7 @@ import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel import dev.zacsweers.metro.Inject import hedvig.resources.ONBOARDING_ADD_COINSURED_TITLE +import hedvig.resources.ONBOARDING_ADD_COOWNERS_TITLE import hedvig.resources.ONBOARDING_ADD_INFO_LATER_LABEL import hedvig.resources.ONBOARDING_DO_THIS_LATER_BUTTON import hedvig.resources.ONBOARDING_MISSING_INFO_SUBTITLE @@ -71,23 +73,27 @@ internal class OnboardingCoInsuredPresenter( var currentState by remember { mutableStateOf(lastState) } var loadIteration by remember { mutableIntStateOf(0) } - // Pin which contracts belong to this step the first time data is available, so completed - // rows render as done instead of disappearing. - var pinnedContractIds by remember { mutableStateOf?>(null) } + // Pin which contracts belong to this step (and each one's flow type) the first time data is + // available, so completed rows render as done instead of disappearing and the header title + // stays stable even after a contract's missing info is filled in. + var pinnedContracts by remember { mutableStateOf>?>(null) } fun contentFrom(session: OnboardingSession): OnboardingCoInsuredUiState.Content { - val ids = pinnedContractIds - ?: session.data.contractsWithMissingCoInsured.map { it.id }.also { pinnedContractIds = it } + val pinned = pinnedContracts + ?: session.data.contractsMissingInsuredOrOwnerInfo + .mapNotNull { contract -> contract.coInsuredFlowType?.let { contract.id to it } } + .also { pinnedContracts = it } return OnboardingCoInsuredUiState.Content( progress = session.progressFor(OnboardingStepId.CoInsured), - rows = ids.mapNotNull { id -> + rows = pinned.mapNotNull { (id, flowType) -> val contract = session.data.contracts.firstOrNull { it.id == id } ?: return@mapNotNull null CoInsuredRow( contractId = contract.id, displayName = contract.displayName, exposureName = contract.exposureName, typeOfContract = contract.typeOfContract, - isComplete = contract.missingCoInsuredCount == 0, + flowType = flowType, + isComplete = contract.coInsuredFlowType == null, ) }, ) @@ -113,7 +119,7 @@ internal class OnboardingCoInsuredPresenter( // onLeft: keep the previously shown state, per the spec's error handling. } - is OnboardingCoInsuredEvent.AddCoInsured -> navigator.openAddCoInsured(event.contractId) + is OnboardingCoInsuredEvent.AddCoInsured -> navigator.openAddCoInsured(event.contractId, event.flowType) OnboardingCoInsuredEvent.Continue -> launch { navigator.continueFrom(OnboardingStepId.CoInsured) } } @@ -128,6 +134,7 @@ internal data class CoInsuredRow( val displayName: String, val exposureName: String, val typeOfContract: String, + val flowType: CoInsuredFlowType, val isComplete: Boolean, ) @@ -149,7 +156,7 @@ internal sealed interface OnboardingCoInsuredEvent { data object Refresh : OnboardingCoInsuredEvent - data class AddCoInsured(val contractId: String) : OnboardingCoInsuredEvent + data class AddCoInsured(val contractId: String, val flowType: CoInsuredFlowType) : OnboardingCoInsuredEvent data object Continue : OnboardingCoInsuredEvent } @@ -187,9 +194,16 @@ internal fun OnboardingCoInsuredDestination(viewModel: OnboardingCoInsuredViewMo } is OnboardingCoInsuredUiState.Content -> { + // The step shows co-owners contracts when that is all it has (e.g. a car contract missing + // an owner); otherwise it is the co-insured case. + val allCoOwners = content.rows.isNotEmpty() && content.rows.all { it.flowType == CoInsuredFlowType.CoOwners } Spacer(Modifier.height(16.dp)) OnboardingStepHeader( - title = stringResource(Res.string.ONBOARDING_ADD_COINSURED_TITLE), + title = if (allCoOwners) { + stringResource(Res.string.ONBOARDING_ADD_COOWNERS_TITLE) + } else { + stringResource(Res.string.ONBOARDING_ADD_COINSURED_TITLE) + }, description = stringResource(Res.string.ONBOARDING_MISSING_INFO_SUBTITLE), ) Spacer(Modifier.weight(1f)) @@ -201,7 +215,7 @@ internal fun OnboardingCoInsuredDestination(viewModel: OnboardingCoInsuredViewMo exposureName = row.exposureName, typeOfContract = row.typeOfContract, isComplete = row.isComplete, - onAddClick = { viewModel.emit(OnboardingCoInsuredEvent.AddCoInsured(row.contractId)) }, + onAddClick = { viewModel.emit(OnboardingCoInsuredEvent.AddCoInsured(row.contractId, row.flowType)) }, ) } } diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt index fa762e7fe6..891223aad9 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt @@ -184,29 +184,11 @@ internal fun OnboardingPaymentDestination(viewModel: OnboardingPaymentViewModel, ) } - OnboardingPayinStatus.Pending -> { - OnboardingStepHeader( - title = stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_TITLE), - // A method exists but is awaiting bank activation, so we must not claim it is - // connected. This can take a few days. - // TODO: Add "Your payment method is being set up. This can take a few days." / - // "Din betalmetod hålller på att sättas upp. Det kan ta några dagar." to Lokalise - description = "Your payment method is being set up. This can take a few days.", - ) - Spacer(Modifier.weight(1f)) - Spacer(Modifier.height(24.dp)) - OnboardingStepButtons( - primaryText = stringResource(Res.string.general_continue_button), - onPrimaryClick = { viewModel.emit(OnboardingPaymentEvent.Continue) }, - ) - } - - OnboardingPayinStatus.Active -> { - OnboardingStepHeader( - title = stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_TITLE), - // TODO: Add "Your payment method is connected" / "Din betalmetod är kopplad" to Lokalise - description = "Your payment method is connected", - ) + // A payin method exists (active, or pending bank activation). The design's connected + // state is just the "switch accounts later" hint plus Continue; we deliberately make no + // claim about the connection being active, so pending and active render the same. + OnboardingPayinStatus.Pending, OnboardingPayinStatus.Active -> { + OnboardingStepHeader(title = stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_TITLE)) Spacer(Modifier.weight(1f)) Spacer(Modifier.height(24.dp)) // TODO: Add "You can switch accounts later in settings" / diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingPathTest.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingPathTest.kt index 1a1052c42b..c192c462f6 100644 --- a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingPathTest.kt +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingPathTest.kt @@ -13,6 +13,7 @@ class OnboardingPathTest { id: String = "contract-1", typeOfContract: String = "SE_APARTMENT_RENT", missingCoInsuredCount: Int = 0, + missingCoOwnersCount: Int = 0, isMissingPetId: Boolean = false, ) = OnboardingContract( id = id, @@ -20,6 +21,7 @@ class OnboardingPathTest { exposureName = "Bellmansgatan 19A", typeOfContract = typeOfContract, missingCoInsuredCount = missingCoInsuredCount, + missingCoOwnersCount = missingCoOwnersCount, isMissingPetId = isMissingPetId, ) @@ -72,6 +74,12 @@ class OnboardingPathTest { ) } + @Test + fun `co-insured step is included when a contract is missing co-owner info`() { + val path = buildOnboardingPath(data(contracts = listOf(contract(missingCoOwnersCount = 1)))) + assertThat(path).contains(OnboardingStepId.CoInsured) + } + @Test fun `co-insured step is skipped when no contract has missing co-insured info`() { val path = buildOnboardingPath(data(contracts = listOf(contract(missingCoInsuredCount = 0)))) diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredPresenterTest.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredPresenterTest.kt index 96f8638613..6b815bcd20 100644 --- a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredPresenterTest.kt +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredPresenterTest.kt @@ -71,12 +71,45 @@ internal class OnboardingCoInsuredPresenterTest { skipItems(1) repository.onboardingDataResponses.add(testOnboardingData().right()) awaitItem() - sendEvent(OnboardingCoInsuredEvent.AddCoInsured("contract-1")) + sendEvent(OnboardingCoInsuredEvent.AddCoInsured("contract-1", CoInsuredFlowType.CoInsured)) runCurrent() assertThat(backstack.entries.last()).isEqualTo(CoInsuredAddInfoKey("contract-1", CoInsuredFlowType.CoInsured)) } } + @Test + fun `a co-owners contract yields a co-owners row and navigates into the co-owners flow`() = runTest { + val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.CoInsured)) } + val repository = FakeOnboardingRepository() + val sessionStore = OnboardingSessionStore(repository, FakeOnboardingMemberIdProvider()) + val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) + val presenter = OnboardingCoInsuredPresenter(sessionStore, navigator) + + presenter.test(OnboardingCoInsuredUiState.Loading) { + skipItems(1) + repository.onboardingDataResponses.add( + testOnboardingData( + contracts = listOf( + OnboardingContract( + id = "car-1", + displayName = "Car Insurance", + exposureName = "ABC 123", + typeOfContract = "SE_CAR", + missingCoInsuredCount = 0, + isMissingPetId = false, + missingCoOwnersCount = 1, + ), + ), + ).right(), + ) + val content = awaitItem() as OnboardingCoInsuredUiState.Content + assertThat(content.rows[0].flowType).isEqualTo(CoInsuredFlowType.CoOwners) + sendEvent(OnboardingCoInsuredEvent.AddCoInsured("car-1", CoInsuredFlowType.CoOwners)) + runCurrent() + assertThat(backstack.entries.last()).isEqualTo(CoInsuredAddInfoKey("car-1", CoInsuredFlowType.CoOwners)) + } + } + @Test fun `refresh marks completed rows instead of removing them`() = runTest { val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.CoInsured)) } From d97ae9508daf4c8f243e4114036d0f8be8880b83 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Mon, 3 Aug 2026 15:44:21 +0200 Subject: [PATCH 076/119] Wire remaining onboarding strings to Lokalise keys --- .../src/androidMain/res/values-sv-rSE/strings.xml | 8 ++++++-- .../core-resources/src/androidMain/res/values/strings.xml | 8 ++++++-- .../commonMain/composeResources/values-sv-rSE/strings.xml | 8 ++++++-- .../src/commonMain/composeResources/values/strings.xml | 8 ++++++-- .../feature/onboarding/ui/OnboardingContractCard.kt | 4 ++-- .../onboarding/ui/bundle/OnboardingBundleDestination.kt | 4 ++-- .../onboarding/ui/payment/OnboardingPaymentDestination.kt | 5 ++--- .../onboarding/ui/phone/OnboardingPhoneDestination.kt | 6 ++++-- 8 files changed, 34 insertions(+), 17 deletions(-) diff --git a/app/core/core-resources/src/androidMain/res/values-sv-rSE/strings.xml b/app/core/core-resources/src/androidMain/res/values-sv-rSE/strings.xml index 4bcfbc51a7..1d719d7c34 100644 --- a/app/core/core-resources/src/androidMain/res/values-sv-rSE/strings.xml +++ b/app/core/core-resources/src/androidMain/res/values-sv-rSE/strings.xml @@ -419,9 +419,7 @@ Din försäkring kommer att uppdateras den %1$s. Bekräfta Kontakta vårt serviceteam - Inaktiverad Ladda ner - Aktiverad Felaktig inmatning Vet inte Försök igen @@ -608,6 +606,7 @@ Startdatum Hoppa över Tillagd + Lägg till Lägg till medförsäkrade Lägg till medägare Du kan lägga till informationen senare @@ -619,6 +618,7 @@ Du kan ändra inställningarna senare Du behöver lägga till ett betalsätt för att din försäkring ska fortsätta gälla Lägg till ett betalsätt för att aktivera din försäkring + Du kan byta konto senare i inställningar Koppla betalning Fortsätt till appen Du får 15 % paketrabatt när du har två eller fler försäkringar hos oss @@ -630,8 +630,10 @@ Det gör det lättare att hjälpa dig om något händer Så att vi vet vilka som omfattas av din försäkring Öppnas i din webbläsare + Kunde inte spara, försök igen Lägg till ditt telefonnummer så att vi kan nå dig om något händer Telefonnummer + Se pris Använd mörkt läge Använd ljust läge Anpassa appens utseende @@ -801,6 +803,8 @@ Systemets standardinställning Tema Inställningar + Inaktiverad + Aktiverad Användningsdata Märke Fyll i ditt inköpspris diff --git a/app/core/core-resources/src/androidMain/res/values/strings.xml b/app/core/core-resources/src/androidMain/res/values/strings.xml index dd71525a85..a2ba718673 100644 --- a/app/core/core-resources/src/androidMain/res/values/strings.xml +++ b/app/core/core-resources/src/androidMain/res/values/strings.xml @@ -419,9 +419,7 @@ Your insurance will be updated starting from %1$s Confirm Contact our service team - Disabled Download - Enabled Invalid input Not sure Try again @@ -608,6 +606,7 @@ Start dates Skip Added + Add Add co-insured Add co-owners You can add this information later @@ -619,6 +618,7 @@ You can change these settings later Adding a payment method is required to keep your insurance active Add a payment method to activate your insurance + You can switch accounts later in settings Connect payment Continue to app You get a 15% bundle discount when you have two or more insurances with us @@ -630,8 +630,10 @@ This makes it easier to help you if something happens So we know who\'s covered by your insurance Opens in your browser + Could not save, please try again Add your phone number so we can reach you if something happens Phone number + See price Set dark mode Set light mode Customize the look of the app @@ -801,6 +803,8 @@ System default Theme Settings + Disabled + Enabled Usage data Brand Fill in your purchase price diff --git a/app/core/core-resources/src/commonMain/composeResources/values-sv-rSE/strings.xml b/app/core/core-resources/src/commonMain/composeResources/values-sv-rSE/strings.xml index 7c303e620a..b55551902e 100644 --- a/app/core/core-resources/src/commonMain/composeResources/values-sv-rSE/strings.xml +++ b/app/core/core-resources/src/commonMain/composeResources/values-sv-rSE/strings.xml @@ -418,9 +418,7 @@ Din försäkring kommer att uppdateras den %1$s. Bekräfta Kontakta vårt serviceteam - Inaktiverad Ladda ner - Aktiverad Felaktig inmatning Vet inte Försök igen @@ -607,6 +605,7 @@ Startdatum Hoppa över Tillagd + Lägg till Lägg till medförsäkrade Lägg till medägare Du kan lägga till informationen senare @@ -618,6 +617,7 @@ Du kan ändra inställningarna senare Du behöver lägga till ett betalsätt för att din försäkring ska fortsätta gälla Lägg till ett betalsätt för att aktivera din försäkring + Du kan byta konto senare i inställningar Koppla betalning Fortsätt till appen Du får 15 % paketrabatt när du har två eller fler försäkringar hos oss @@ -629,8 +629,10 @@ Det gör det lättare att hjälpa dig om något händer Så att vi vet vilka som omfattas av din försäkring Öppnas i din webbläsare + Kunde inte spara, försök igen Lägg till ditt telefonnummer så att vi kan nå dig om något händer Telefonnummer + Se pris Använd mörkt läge Använd ljust läge Anpassa appens utseende @@ -800,6 +802,8 @@ Systemets standardinställning Tema Inställningar + Inaktiverad + Aktiverad Användningsdata Märke Fyll i ditt inköpspris diff --git a/app/core/core-resources/src/commonMain/composeResources/values/strings.xml b/app/core/core-resources/src/commonMain/composeResources/values/strings.xml index 634c7fb75b..6f8d4811df 100644 --- a/app/core/core-resources/src/commonMain/composeResources/values/strings.xml +++ b/app/core/core-resources/src/commonMain/composeResources/values/strings.xml @@ -418,9 +418,7 @@ Your insurance will be updated starting from %1$s Confirm Contact our service team - Disabled Download - Enabled Invalid input Not sure Try again @@ -607,6 +605,7 @@ Start dates Skip Added + Add Add co-insured Add co-owners You can add this information later @@ -618,6 +617,7 @@ You can change these settings later Adding a payment method is required to keep your insurance active Add a payment method to activate your insurance + You can switch accounts later in settings Connect payment Continue to app You get a 15% bundle discount when you have two or more insurances with us @@ -629,8 +629,10 @@ This makes it easier to help you if something happens So we know who's covered by your insurance Opens in your browser + Could not save, please try again Add your phone number so we can reach you if something happens Phone number + See price Set dark mode Set light mode Customize the look of the app @@ -800,6 +802,8 @@ System default Theme Settings + Disabled + Enabled Usage data Brand Fill in your purchase price diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingContractCard.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingContractCard.kt index c2514bcbc9..5ecb2a7dd5 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingContractCard.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingContractCard.kt @@ -25,6 +25,7 @@ import com.hedvig.android.design.system.hedvig.Icon import com.hedvig.android.design.system.hedvig.icon.Checkmark import com.hedvig.android.design.system.hedvig.icon.HedvigIcons import hedvig.resources.ONBOARDING_ADDED_LABEL +import hedvig.resources.ONBOARDING_ADD_BUTTON import hedvig.resources.Res import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource @@ -77,8 +78,7 @@ internal fun OnboardingContractCard( Icon(imageVector = HedvigIcons.Checkmark, contentDescription = null) } else { HedvigButton( - // TODO: Add "Add" / "Lägg till" to Lokalise - text = "Add", + text = stringResource(Res.string.ONBOARDING_ADD_BUTTON), onClick = onAddClick, enabled = true, buttonStyle = ButtonDefaults.ButtonStyle.Primary, diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt index 23c09c995f..f05f7846dd 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt @@ -51,6 +51,7 @@ import hedvig.resources.ONBOARDING_CONTINUE_TO_APP_BUTTON import hedvig.resources.ONBOARDING_CROSS_SELL_SUBTITLE import hedvig.resources.ONBOARDING_CROSS_SELL_TITLE import hedvig.resources.ONBOARDING_OPENS_IN_BROWSER_HINT +import hedvig.resources.ONBOARDING_SEE_PRICE_BUTTON import hedvig.resources.Res import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource @@ -223,8 +224,7 @@ private fun OnboardingCrossSellRow( ) } HedvigButton( - // TODO: Add "See price" / "Se pris" to Lokalise - text = "See price", + text = stringResource(Res.string.ONBOARDING_SEE_PRICE_BUTTON), onClick = { openUrl(crossSell.storeUrl) }, enabled = true, buttonStyle = ButtonDefaults.ButtonStyle.Secondary, diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt index 891223aad9..7493c7b131 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt @@ -38,6 +38,7 @@ import com.hedvig.android.molecule.public.MoleculeViewModel import dev.zacsweers.metro.Inject import hedvig.resources.ONBOARDING_CONNECT_PAYMENT_FOOTNOTE import hedvig.resources.ONBOARDING_CONNECT_PAYMENT_SUBTITLE +import hedvig.resources.ONBOARDING_CONNECT_PAYMENT_SWITCH_ACCOUNTS_LATER import hedvig.resources.ONBOARDING_CONNECT_PAYMENT_TITLE import hedvig.resources.Res import hedvig.resources.general_continue_button @@ -191,10 +192,8 @@ internal fun OnboardingPaymentDestination(viewModel: OnboardingPaymentViewModel, OnboardingStepHeader(title = stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_TITLE)) Spacer(Modifier.weight(1f)) Spacer(Modifier.height(24.dp)) - // TODO: Add "You can switch accounts later in settings" / - // "Du kan byta konto senare i inställningar" to Lokalise HedvigText( - text = "You can switch accounts later in settings", + text = stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_SWITCH_ACCOUNTS_LATER), style = HedvigTheme.typography.finePrint, color = HedvigTheme.colorScheme.textSecondary, modifier = Modifier.align(Alignment.CenterHorizontally), diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt index 91b716485f..c118dc11f7 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt @@ -40,6 +40,7 @@ import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel import dev.zacsweers.metro.Inject import hedvig.resources.ONBOARDING_DO_THIS_LATER_BUTTON +import hedvig.resources.ONBOARDING_PHONE_SAVE_ERROR import hedvig.resources.ONBOARDING_PHONE_SUBTITLE import hedvig.resources.ONBOARDING_PHONE_TITLE import hedvig.resources.Res @@ -207,8 +208,9 @@ internal fun OnboardingPhoneDestination(viewModel: OnboardingPhoneViewModel, nav labelText = stringResource(Res.string.ONBOARDING_PHONE_TITLE), textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, errorState = if (content.showSubmissionError) { - // TODO: Add "Could not save, please try again" / "Kunde inte spara, försök igen" to Lokalise - HedvigTextFieldDefaults.ErrorState.Error.WithMessage("Could not save, please try again") + HedvigTextFieldDefaults.ErrorState.Error.WithMessage( + stringResource(Res.string.ONBOARDING_PHONE_SAVE_ERROR), + ) } else { HedvigTextFieldDefaults.ErrorState.NoError }, From 2e030ab072f5be1291d215caec144690c8312fc0 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Mon, 3 Aug 2026 16:09:05 +0200 Subject: [PATCH 077/119] Make co-insured and co-owners counts symmetric, tighten two comments --- .../hedvig/android/feature/onboarding/data/OnboardingData.kt | 2 +- .../android/feature/onboarding/data/OnboardingRepository.kt | 4 ++-- .../onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt | 4 ++-- .../android/feature/onboarding/FakeOnboardingRepository.kt | 1 + .../ui/coinsured/OnboardingCoInsuredPresenterTest.kt | 3 ++- .../onboarding/ui/petid/OnboardingPetIdPresenterTest.kt | 4 ++++ 6 files changed, 12 insertions(+), 6 deletions(-) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingData.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingData.kt index ce8d640a4a..44e344953d 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingData.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingData.kt @@ -45,8 +45,8 @@ internal data class OnboardingContract( val exposureName: String, val typeOfContract: String, val missingCoInsuredCount: Int, + val missingCoOwnersCount: Int, val isMissingPetId: Boolean, - val missingCoOwnersCount: Int = 0, ) { /** * Which edit flow this contract's missing info needs, or null when nothing is missing. Co-owners diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingRepository.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingRepository.kt index 17425757ed..7cdde61762 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingRepository.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingRepository.kt @@ -42,8 +42,8 @@ internal class OnboardingRepositoryImpl( displayName = contract.currentAgreement.productVariant.displayName, exposureName = contract.exposureDisplayNameShort, typeOfContract = contract.currentAgreement.productVariant.typeOfContract, - // Mirror the home-screen reminder rule: only count still-active (not terminating) entries - // whose type the contract actually supports. + // Terminating entries are excluded (their missing info is moot), matching the home-screen + // "needs co-insured info" reminder so both surfaces agree on what still counts as missing. missingCoInsuredCount = if (contract.supportsCoInsured) { contract.coInsured.orEmpty().count { it.hasMissingInfo && it.terminatesOn == null } } else { diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt index d4e6794466..7c69f1c0bd 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt @@ -194,8 +194,8 @@ internal fun OnboardingCoInsuredDestination(viewModel: OnboardingCoInsuredViewMo } is OnboardingCoInsuredUiState.Content -> { - // The step shows co-owners contracts when that is all it has (e.g. a car contract missing - // an owner); otherwise it is the co-insured case. + // The step has a single title but its rows can mix types, so use the co-owners title only + // when every row is co-owners and fall back to co-insured otherwise (including the mix). val allCoOwners = content.rows.isNotEmpty() && content.rows.all { it.flowType == CoInsuredFlowType.CoOwners } Spacer(Modifier.height(16.dp)) OnboardingStepHeader( diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/FakeOnboardingRepository.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/FakeOnboardingRepository.kt index 15089dd667..b2ff4c8b21 100644 --- a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/FakeOnboardingRepository.kt +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/FakeOnboardingRepository.kt @@ -40,6 +40,7 @@ internal fun testOnboardingData( exposureName = "Bellmansgatan 19A", typeOfContract = "SE_APARTMENT_RENT", missingCoInsuredCount = 1, + missingCoOwnersCount = 0, isMissingPetId = false, ), ), diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredPresenterTest.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredPresenterTest.kt index 6b815bcd20..c05f5dbb2e 100644 --- a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredPresenterTest.kt +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredPresenterTest.kt @@ -96,8 +96,8 @@ internal class OnboardingCoInsuredPresenterTest { exposureName = "ABC 123", typeOfContract = "SE_CAR", missingCoInsuredCount = 0, - isMissingPetId = false, missingCoOwnersCount = 1, + isMissingPetId = false, ), ), ).right(), @@ -133,6 +133,7 @@ internal class OnboardingCoInsuredPresenterTest { exposureName = "Bellmansgatan 19A", typeOfContract = "SE_APARTMENT_RENT", missingCoInsuredCount = 0, + missingCoOwnersCount = 0, isMissingPetId = false, ), ), diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdPresenterTest.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdPresenterTest.kt index 1ad9db686e..22641a72f3 100644 --- a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdPresenterTest.kt +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdPresenterTest.kt @@ -54,6 +54,7 @@ internal class OnboardingPetIdPresenterTest { exposureName = "Bellmansgatan 19A", typeOfContract = "SE_APARTMENT_RENT", missingCoInsuredCount = 0, + missingCoOwnersCount = 0, isMissingPetId = true, ), ), @@ -87,6 +88,7 @@ internal class OnboardingPetIdPresenterTest { exposureName = "Bellmansgatan 19A", typeOfContract = "SE_APARTMENT_RENT", missingCoInsuredCount = 0, + missingCoOwnersCount = 0, isMissingPetId = true, ), ), @@ -104,6 +106,7 @@ internal class OnboardingPetIdPresenterTest { exposureName = "Bellmansgatan 19A", typeOfContract = "SE_APARTMENT_RENT", missingCoInsuredCount = 0, + missingCoOwnersCount = 0, isMissingPetId = false, ), ), @@ -137,6 +140,7 @@ internal class OnboardingPetIdPresenterTest { exposureName = "Bellmansgatan 19A", typeOfContract = "SE_APARTMENT_RENT", missingCoInsuredCount = 0, + missingCoOwnersCount = 0, isMissingPetId = true, ), ), From c1c417bcf71cb3c53681ec75c5ade5b2d15a5941 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Mon, 3 Aug 2026 16:59:21 +0200 Subject: [PATCH 078/119] Add spec for staggered invite-card friend animation --- ...onboarding-invite-card-animation-design.md | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-03-onboarding-invite-card-animation-design.md diff --git a/docs/superpowers/specs/2026-08-03-onboarding-invite-card-animation-design.md b/docs/superpowers/specs/2026-08-03-onboarding-invite-card-animation-design.md new file mode 100644 index 0000000000..8959a7f371 --- /dev/null +++ b/docs/superpowers/specs/2026-08-03-onboarding-invite-card-animation-design.md @@ -0,0 +1,52 @@ +# Staggered friend-row entrance on the onboarding invite step + +## Goal + +Give the invite-a-friend onboarding step a playful feel: the example referrals card starts +with one friend row visible, then the 2nd and 3rd rows animate in one after the other, the +card visibly growing with each reveal. Timing values will be tuned together with the +designer afterwards, so they must be easy to find and tweak. + +## Behavior + +- On first arrival at the invite step (once `Content` is showing), the card shows only the + first row ("Hampus"). After a short pause the 2nd row expands and fades in, then after + another pause the 3rd row does the same. +- The animation plays only the very first time. A completion flag lives on + `OnboardingInviteViewModel`, which is retained while the invite entry is on the back + stack, so returning to the step (from the next step, or after backgrounding) shows all + three rows immediately with no replay. +- Leaving onboarding entirely and starting again creates a new ViewModel, so the animation + replays. Process death also loses the flag and replays. Both are acceptable. +- If the user leaves mid-sequence, the flag is not set (it is set only on completion), so + the next fresh visit replays the full sequence. +- Loading and Error states are untouched; the sequence starts when `Content` first + composes. + +## Implementation shape + +All changes are in `OnboardingInviteDestination.kt` in `:feature-onboarding`; no +presenter/UiState changes. + +- `OnboardingInviteViewModel` gains a plain `var inviteCardAnimationPlayed = false`. +- `ExampleReferralsCard` drives a `visibleRows` state (1..3) from a `LaunchedEffect`: if + the flag is already set, start at 3; otherwise start at 1, delay, reveal row 2, delay, + reveal row 3, then set the flag. +- Rows 2 and 3, each together with its preceding `HorizontalDivider`, are wrapped in + `AnimatedVisibility(enter = expandVertically(...) + fadeIn(...))`, so the card grows + vertically as each row appears. The surrounding `weight(1f)` spacers keep the card + centered while it grows. +- Row 1 is always visible. + +## Timing defaults (designer-tweakable, named constants grouped in one place) + +- 450 ms pause after the card first appears before row 2 starts. +- 600 ms between row 2 and row 3. +- Each reveal animates over 400 ms (`MotionTokens.DurationMedium4`) using + `MotionTokens.EasingEmphasizedDecelerateCubicBezier` for the expand, combined with a + fade-in. + +## Testing + +No unit-testable logic is added; timing lives in the composable. Verification is visual, +matching the plan to iterate on the values with the designer. From 2bb2846dfc51a129a7bf55e04cbea32e5fe6108d Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Mon, 3 Aug 2026 17:01:45 +0200 Subject: [PATCH 079/119] Add plan for staggered invite-card friend animation --- ...-08-03-onboarding-invite-card-animation.md | 189 ++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-03-onboarding-invite-card-animation.md diff --git a/docs/superpowers/plans/2026-08-03-onboarding-invite-card-animation.md b/docs/superpowers/plans/2026-08-03-onboarding-invite-card-animation.md new file mode 100644 index 0000000000..ad4e1711d0 --- /dev/null +++ b/docs/superpowers/plans/2026-08-03-onboarding-invite-card-animation.md @@ -0,0 +1,189 @@ +# Onboarding Invite Card Staggered Animation 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:** Animate the example referrals card on the onboarding invite step so it starts with one friend row and staggers in the 2nd and 3rd rows, playing only the very first time the step is seen. + +**Architecture:** Pure UI-driven staggering inside `ExampleReferralsCard` in `OnboardingInviteDestination.kt`; a `LaunchedEffect` advances a `visibleRows` counter with delays, rows 2 and 3 enter via `AnimatedVisibility(expandVertically + fadeIn)`, and a plain "played" flag on `OnboardingInviteViewModel` (retained while the invite entry is on the back stack) suppresses replays. No presenter or UiState changes. + +**Tech Stack:** Jetpack Compose animation (`AnimatedVisibility`, `expandVertically`, `fadeIn`, `tween`), design-system `MotionTokens`, `kotlinx.coroutines.delay`. + +## Global Constraints + +- Spec: `docs/superpowers/specs/2026-08-03-onboarding-invite-card-animation-design.md`. +- All changes in one file: `app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt`. +- Timing values must be named constants grouped in one place, easy to tweak with the designer: 450 ms initial pause, 600 ms stagger, 400 ms reveal (`MotionTokens.DurationMedium4`) with `MotionTokens.EasingEmphasizedDecelerateCubicBezier`. +- The "played" flag is set only when the sequence completes. +- No unit tests: the change is pure presentation timing with no extractable logic (per spec); verification is compile + ktlint + visual pass with the designer. +- ktlint: 2-space indent, 120-char lines, trailing commas. Run `./gradlew ktlintFormat` before committing. +- When checking Gradle output, grep for `BUILD SUCCESSFUL` explicitly; piping to `tail`/`grep` masks the exit code. + +--- + +### Task 1: Staggered row entrance in ExampleReferralsCard + +**Files:** +- Modify: `app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt` + +**Interfaces:** +- Consumes: existing `OnboardingInviteViewModel`, `ExampleReferralsCard`, design-system `MotionTokens` (already used elsewhere in `:feature-onboarding`). +- Produces: `OnboardingInviteViewModel.inviteCardAnimationPlayed: Boolean` (plain `var`, default `false`); `ExampleReferralsCard(incentiveDisplay: String, animationAlreadyPlayed: Boolean, onAnimationCompleted: () -> Unit, modifier: Modifier)`. Nothing outside this file consumes either. + +- [ ] **Step 1: Add the played flag to the ViewModel** + +In `OnboardingInviteViewModel`, add a body with the flag (the class currently has no body): + +```kotlin +@Inject +@HedvigViewModel(ActivityRetainedScope::class) +internal class OnboardingInviteViewModel( + sessionStore: OnboardingSessionStore, + navigator: OnboardingNavigator, + val progressBarAnimation: OnboardingProgressBarAnimation, +) : MoleculeViewModel( + initialState = OnboardingInviteUiState.Loading, + presenter = OnboardingInvitePresenter(sessionStore, navigator), + ) { + /** + * The example referrals card staggers its rows in the first time it is seen. Lives here so the + * one-shot survives leaving and returning to this step within one onboarding session. + */ + var inviteCardAnimationPlayed: Boolean = false +} +``` + +- [ ] **Step 2: Rewrite ExampleReferralsCard with the staggered reveal** + +Replace the current `ExampleReferralsCard` with the version below, and extract the row content into a private `ExampleReferralRow` so the static first row and the animated rows share it: + +```kotlin +@Composable +private fun ExampleReferralsCard( + incentiveDisplay: String, + animationAlreadyPlayed: Boolean, + onAnimationCompleted: () -> Unit, + modifier: Modifier = Modifier, +) { + // Illustrative, hardcoded example names showing what the referral list looks like once populated. + val exampleNames = listOf("Hampus", "Li", "Elin") + var visibleRows by remember { mutableIntStateOf(if (animationAlreadyPlayed) exampleNames.size else 1) } + LaunchedEffect(Unit) { + if (animationAlreadyPlayed) return@LaunchedEffect + delay(RowRevealInitialDelayMillis) + visibleRows = 2 + delay(RowRevealStaggerDelayMillis) + visibleRows = 3 + onAnimationCompleted() + } + Surface( + modifier = modifier, + shape = HedvigTheme.shapes.cornerLarge, + color = HedvigTheme.colorScheme.surfacePrimary, + ) { + Column(Modifier.padding(horizontal = 24.dp, vertical = 16.dp)) { + exampleNames.forEachIndexed { index, name -> + if (index == 0) { + ExampleReferralRow(name = name, incentiveDisplay = incentiveDisplay) + } else { + AnimatedVisibility( + visible = visibleRows > index, + enter = expandVertically(rowRevealAnimationSpec()) + fadeIn(rowRevealAnimationSpec()), + ) { + Column { + HorizontalDivider() + ExampleReferralRow(name = name, incentiveDisplay = incentiveDisplay) + } + } + } + } + } + } +} + +@Composable +private fun ExampleReferralRow(name: String, incentiveDisplay: String) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 12.dp), + ) { + Box( + Modifier + .size(8.dp) + .clip(CircleShape) + .background(HedvigTheme.colorScheme.signalGreenElement), + ) + Spacer(Modifier.width(8.dp)) + HedvigText(text = name, style = HedvigTheme.typography.bodySmall) + Spacer(Modifier.weight(1f)) + HedvigText(text = "-$incentiveDisplay", style = HedvigTheme.typography.bodySmall) + } +} + +// Reveal timing for the example referral rows, grouped for easy tuning with design. +private const val RowRevealInitialDelayMillis = 450L +private const val RowRevealStaggerDelayMillis = 600L + +private fun rowRevealAnimationSpec(): TweenSpec = tween( + durationMillis = MotionTokens.DurationMedium4.toInt(), + easing = MotionTokens.EasingEmphasizedDecelerateCubicBezier, +) +``` + +Note: `expandVertically` takes an `FiniteAnimationSpec` and `fadeIn` a `FiniteAnimationSpec`, hence the generic helper. If ktlint or the compiler complains about the generic function, inline the two `tween(...)` calls into `expandVertically`/`fadeIn` instead and keep the duration/easing referenced from `MotionTokens` directly. + +- [ ] **Step 3: Wire the card call site to the ViewModel flag** + +In `OnboardingInviteDestination`, update the `ExampleReferralsCard` call inside the `Content` branch: + +```kotlin +ExampleReferralsCard( + incentiveDisplay = state.incentiveDisplay, + animationAlreadyPlayed = viewModel.inviteCardAnimationPlayed, + onAnimationCompleted = { viewModel.inviteCardAnimationPlayed = true }, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .fillMaxWidth(0.72f), +) +``` + +- [ ] **Step 4: Add the new imports** + +Add to the existing import list (keep alphabetical order, no wildcards): + +```kotlin +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.TweenSpec +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import com.hedvig.android.design.system.hedvig.tokens.MotionTokens +import kotlinx.coroutines.delay +``` + +`mutableIntStateOf`, `getValue`/`setValue`, and `remember` are already imported. + +- [ ] **Step 5: Format and compile** + +Run: + +```bash +./gradlew :feature-onboarding:ktlintFormat :feature-onboarding:compileDebugKotlin +``` + +Expected: output contains `BUILD SUCCESSFUL` (check the full output, not a piped tail). + +- [ ] **Step 6: Commit** + +```bash +git add app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt +git commit -m "Stagger in the example referral rows on the onboarding invite step" +``` + +- [ ] **Step 7: Visual verification (manual)** + +Install the develop build on a device/emulator and walk the onboarding flow to the invite step. Verify: +- Card appears with only "Hampus", then "Li" expands+fades in after ~450 ms, then "Elin" ~600 ms later. +- Card stays horizontally/vertically centered while growing. +- Continue to the next step, navigate back: all three rows show immediately, no replay. + +This step is the designer-tuning entry point; timing constants live at the bottom of `OnboardingInviteDestination.kt`. From 6dbee699c832ea23d78353442b52aa4f8ee9bea5 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Mon, 3 Aug 2026 17:29:22 +0200 Subject: [PATCH 080/119] Stagger in the example referral rows on the onboarding invite step --- .../ui/invite/OnboardingInviteDestination.kt | 90 +++++++++++++++---- 1 file changed, 71 insertions(+), 19 deletions(-) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt index dcca5b4836..481ddf92b6 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt @@ -1,5 +1,10 @@ package com.hedvig.android.feature.onboarding.ui.invite +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.TweenSpec +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -31,6 +36,7 @@ import com.hedvig.android.design.system.hedvig.HedvigText import com.hedvig.android.design.system.hedvig.HedvigTheme import com.hedvig.android.design.system.hedvig.HorizontalDivider import com.hedvig.android.design.system.hedvig.Surface +import com.hedvig.android.design.system.hedvig.tokens.MotionTokens import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId @@ -48,6 +54,7 @@ import hedvig.resources.ONBOARDING_INVITE_FRIEND_SUBTITLE import hedvig.resources.ONBOARDING_INVITE_FRIEND_TITLE import hedvig.resources.Res import hedvig.resources.general_continue_button +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource @@ -60,7 +67,13 @@ internal class OnboardingInviteViewModel( ) : MoleculeViewModel( initialState = OnboardingInviteUiState.Loading, presenter = OnboardingInvitePresenter(sessionStore, navigator), - ) + ) { + /** + * The example referrals card staggers its rows in the first time it is seen. Lives here so the + * one-shot survives leaving and returning to this step within one onboarding session. + */ + var inviteCardAnimationPlayed: Boolean = false +} internal class OnboardingInvitePresenter( private val sessionStore: OnboardingSessionStore, @@ -158,6 +171,8 @@ internal fun OnboardingInviteDestination(viewModel: OnboardingInviteViewModel, n Spacer(Modifier.height(24.dp)) ExampleReferralsCard( incentiveDisplay = state.incentiveDisplay, + animationAlreadyPlayed = viewModel.inviteCardAnimationPlayed, + onAnimationCompleted = { viewModel.inviteCardAnimationPlayed = true }, modifier = Modifier .align(Alignment.CenterHorizontally) .fillMaxWidth(0.72f), @@ -177,9 +192,23 @@ internal fun OnboardingInviteDestination(viewModel: OnboardingInviteViewModel, n } @Composable -private fun ExampleReferralsCard(incentiveDisplay: String, modifier: Modifier = Modifier) { +private fun ExampleReferralsCard( + incentiveDisplay: String, + animationAlreadyPlayed: Boolean, + onAnimationCompleted: () -> Unit, + modifier: Modifier = Modifier, +) { // Illustrative, hardcoded example names showing what the referral list looks like once populated. val exampleNames = listOf("Hampus", "Li", "Elin") + var visibleRows by remember { mutableIntStateOf(if (animationAlreadyPlayed) exampleNames.size else 1) } + LaunchedEffect(Unit) { + if (animationAlreadyPlayed) return@LaunchedEffect + delay(RowRevealInitialDelayMillis) + visibleRows = 2 + delay(RowRevealStaggerDelayMillis) + visibleRows = 3 + onAnimationCompleted() + } Surface( modifier = modifier, shape = HedvigTheme.shapes.cornerLarge, @@ -187,25 +216,48 @@ private fun ExampleReferralsCard(incentiveDisplay: String, modifier: Modifier = ) { Column(Modifier.padding(horizontal = 24.dp, vertical = 16.dp)) { exampleNames.forEachIndexed { index, name -> - if (index > 0) { - HorizontalDivider() - } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = 12.dp), - ) { - Box( - Modifier - .size(8.dp) - .clip(CircleShape) - .background(HedvigTheme.colorScheme.signalGreenElement), - ) - Spacer(Modifier.width(8.dp)) - HedvigText(text = name, style = HedvigTheme.typography.bodySmall) - Spacer(Modifier.weight(1f)) - HedvigText(text = "-$incentiveDisplay", style = HedvigTheme.typography.bodySmall) + if (index == 0) { + ExampleReferralRow(name = name, incentiveDisplay = incentiveDisplay) + } else { + AnimatedVisibility( + visible = visibleRows > index, + enter = expandVertically(rowRevealAnimationSpec()) + fadeIn(rowRevealAnimationSpec()), + ) { + Column { + HorizontalDivider() + ExampleReferralRow(name = name, incentiveDisplay = incentiveDisplay) + } + } } } } } } + +@Composable +private fun ExampleReferralRow(name: String, incentiveDisplay: String) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 12.dp), + ) { + Box( + Modifier + .size(8.dp) + .clip(CircleShape) + .background(HedvigTheme.colorScheme.signalGreenElement), + ) + Spacer(Modifier.width(8.dp)) + HedvigText(text = name, style = HedvigTheme.typography.bodySmall) + Spacer(Modifier.weight(1f)) + HedvigText(text = "-$incentiveDisplay", style = HedvigTheme.typography.bodySmall) + } +} + +// Reveal timing for the example referral rows, grouped for easy tuning with design. +private const val RowRevealInitialDelayMillis = 450L +private const val RowRevealStaggerDelayMillis = 600L + +private fun rowRevealAnimationSpec(): TweenSpec = tween( + durationMillis = MotionTokens.DurationMedium4.toInt(), + easing = MotionTokens.EasingEmphasizedDecelerateCubicBezier, +) From 5d47bbbdb92a3c76ec928af8e43a39da504f3c18 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 4 Aug 2026 12:04:00 +0200 Subject: [PATCH 081/119] Download strings --- .../src/androidMain/res/values-sv-rSE/strings.xml | 2 ++ app/core/core-resources/src/androidMain/res/values/strings.xml | 2 ++ .../src/commonMain/composeResources/values-sv-rSE/strings.xml | 2 ++ .../src/commonMain/composeResources/values/strings.xml | 2 ++ 4 files changed, 8 insertions(+) diff --git a/app/core/core-resources/src/androidMain/res/values-sv-rSE/strings.xml b/app/core/core-resources/src/androidMain/res/values-sv-rSE/strings.xml index 1d719d7c34..7db4214440 100644 --- a/app/core/core-resources/src/androidMain/res/values-sv-rSE/strings.xml +++ b/app/core/core-resources/src/androidMain/res/values-sv-rSE/strings.xml @@ -419,7 +419,9 @@ Din försäkring kommer att uppdateras den %1$s. Bekräfta Kontakta vårt serviceteam + Inaktiverad Ladda ner + Aktiverad Felaktig inmatning Vet inte Försök igen diff --git a/app/core/core-resources/src/androidMain/res/values/strings.xml b/app/core/core-resources/src/androidMain/res/values/strings.xml index a2ba718673..215d35fdd0 100644 --- a/app/core/core-resources/src/androidMain/res/values/strings.xml +++ b/app/core/core-resources/src/androidMain/res/values/strings.xml @@ -419,7 +419,9 @@ Your insurance will be updated starting from %1$s Confirm Contact our service team + Disabled Download + Enabled Invalid input Not sure Try again diff --git a/app/core/core-resources/src/commonMain/composeResources/values-sv-rSE/strings.xml b/app/core/core-resources/src/commonMain/composeResources/values-sv-rSE/strings.xml index b55551902e..affeaca271 100644 --- a/app/core/core-resources/src/commonMain/composeResources/values-sv-rSE/strings.xml +++ b/app/core/core-resources/src/commonMain/composeResources/values-sv-rSE/strings.xml @@ -418,7 +418,9 @@ Din försäkring kommer att uppdateras den %1$s. Bekräfta Kontakta vårt serviceteam + Inaktiverad Ladda ner + Aktiverad Felaktig inmatning Vet inte Försök igen diff --git a/app/core/core-resources/src/commonMain/composeResources/values/strings.xml b/app/core/core-resources/src/commonMain/composeResources/values/strings.xml index 6f8d4811df..2bd35dab67 100644 --- a/app/core/core-resources/src/commonMain/composeResources/values/strings.xml +++ b/app/core/core-resources/src/commonMain/composeResources/values/strings.xml @@ -418,7 +418,9 @@ Your insurance will be updated starting from %1$s Confirm Contact our service team + Disabled Download + Enabled Invalid input Not sure Try again From 127b9aaee65800dca7cc477231c2fb7d6ecb6e42 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 4 Aug 2026 14:18:43 +0200 Subject: [PATCH 082/119] Match the invite example card to the Figma design and refine its reveal animation Align the example referrals card with the P2 design: white background with a translucent border and the shared elevation shadow, 16dp dots, 16dp row padding, and the divider drawn via the horizontalDivider modifier in its lighter border color. Make the staggered reveal more fluid (longer duration, bigger gap between rows) and expand each row from the top so its divider fades in instead of popping. Give the name the remaining width so the incentive stays tight and survives large font scales. --- .../ui/invite/OnboardingInviteDestination.kt | 54 ++++++++++++------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt index 481ddf92b6..f21b6ffc8f 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt @@ -30,12 +30,14 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.hedvig.android.core.common.di.ActivityRetainedScope import com.hedvig.android.core.common.di.HedvigViewModel +import com.hedvig.android.design.system.hedvig.DividerPosition import com.hedvig.android.design.system.hedvig.HedvigErrorSection import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgressDebounced import com.hedvig.android.design.system.hedvig.HedvigText import com.hedvig.android.design.system.hedvig.HedvigTheme -import com.hedvig.android.design.system.hedvig.HorizontalDivider import com.hedvig.android.design.system.hedvig.Surface +import com.hedvig.android.design.system.hedvig.hedvigDropShadow +import com.hedvig.android.design.system.hedvig.horizontalDivider import com.hedvig.android.design.system.hedvig.tokens.MotionTokens import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator @@ -54,6 +56,7 @@ import hedvig.resources.ONBOARDING_INVITE_FRIEND_SUBTITLE import hedvig.resources.ONBOARDING_INVITE_FRIEND_TITLE import hedvig.resources.Res import hedvig.resources.general_continue_button +import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource @@ -203,30 +206,38 @@ private fun ExampleReferralsCard( var visibleRows by remember { mutableIntStateOf(if (animationAlreadyPlayed) exampleNames.size else 1) } LaunchedEffect(Unit) { if (animationAlreadyPlayed) return@LaunchedEffect - delay(RowRevealInitialDelayMillis) + delay(RowRevealInitialDelayMillis.milliseconds) visibleRows = 2 - delay(RowRevealStaggerDelayMillis) + delay(RowRevealStaggerDelayMillis.milliseconds) visibleRows = 3 onAnimationCompleted() } Surface( - modifier = modifier, + modifier = modifier.hedvigDropShadow(HedvigTheme.shapes.cornerLarge), shape = HedvigTheme.shapes.cornerLarge, - color = HedvigTheme.colorScheme.surfacePrimary, + color = HedvigTheme.colorScheme.backgroundPrimary, + border = HedvigTheme.colorScheme.borderPrimary, ) { - Column(Modifier.padding(horizontal = 24.dp, vertical = 16.dp)) { + Column(Modifier.padding(horizontal = 16.dp)) { exampleNames.forEachIndexed { index, name -> if (index == 0) { ExampleReferralRow(name = name, incentiveDisplay = incentiveDisplay) } else { AnimatedVisibility( visible = visibleRows > index, - enter = expandVertically(rowRevealAnimationSpec()) + fadeIn(rowRevealAnimationSpec()), + // Expand from the top so the divider is unclipped from the first frames and fades in with + // the row, rather than popping in when the clip reaches it at the end of the expansion. + enter = expandVertically(rowRevealAnimationSpec(), expandFrom = Alignment.Top) + + fadeIn(rowRevealAnimationSpec()), ) { - Column { - HorizontalDivider() - ExampleReferralRow(name = name, incentiveDisplay = incentiveDisplay) - } + ExampleReferralRow( + name = name, + incentiveDisplay = incentiveDisplay, + modifier = Modifier.horizontalDivider( + position = DividerPosition.Top, + color = HedvigTheme.colorScheme.borderPrimary, + ), + ) } } } @@ -235,29 +246,32 @@ private fun ExampleReferralsCard( } @Composable -private fun ExampleReferralRow(name: String, incentiveDisplay: String) { +private fun ExampleReferralRow(name: String, incentiveDisplay: String, modifier: Modifier = Modifier) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = 12.dp), + modifier = modifier.padding(vertical = 16.dp), ) { Box( Modifier - .size(8.dp) + .size(16.dp) .clip(CircleShape) .background(HedvigTheme.colorScheme.signalGreenElement), ) Spacer(Modifier.width(8.dp)) - HedvigText(text = name, style = HedvigTheme.typography.bodySmall) - Spacer(Modifier.weight(1f)) + // The name takes the remaining width so the incentive stays right-aligned and always fits, even + // at large font scales where the name wraps, keeping the gap between them tight rather than + // ballooning on wide (e.g. landscape) layouts. + HedvigText(text = name, style = HedvigTheme.typography.bodySmall, modifier = Modifier.weight(1f)) + Spacer(Modifier.width(8.dp)) HedvigText(text = "-$incentiveDisplay", style = HedvigTheme.typography.bodySmall) } } -// Reveal timing for the example referral rows, grouped for easy tuning with design. -private const val RowRevealInitialDelayMillis = 450L -private const val RowRevealStaggerDelayMillis = 600L +private const val RowRevealInitialDelayMillis = 550L +private const val RowRevealStaggerDelayMillis = 1000L +private const val RowRevealDurationMillis = MotionTokens.DurationLong4.toInt() private fun rowRevealAnimationSpec(): TweenSpec = tween( - durationMillis = MotionTokens.DurationMedium4.toInt(), + durationMillis = RowRevealDurationMillis, easing = MotionTokens.EasingEmphasizedDecelerateCubicBezier, ) From 0ec59e249cf9f27e18dbabbcefe954a64089c4b6 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 4 Aug 2026 14:19:03 +0200 Subject: [PATCH 083/119] Move the invite-card one-shot animation flag into MVI state and cap the card width Replace the plain var on the ViewModel with presenter state surfaced through Content and flipped by an InviteCardAnimationCompleted event, matching the app's MVI pattern. Wrap the card so its width is min(72% of available, 280dp), keeping side margins in portrait while stopping it from stretching (and its rows spreading apart) on wide, e.g. landscape, layouts. --- .../ui/invite/OnboardingInviteDestination.kt | 38 ++++++++++++------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt index f21b6ffc8f..635d9b5efc 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -70,13 +71,7 @@ internal class OnboardingInviteViewModel( ) : MoleculeViewModel( initialState = OnboardingInviteUiState.Loading, presenter = OnboardingInvitePresenter(sessionStore, navigator), - ) { - /** - * The example referrals card staggers its rows in the first time it is seen. Lives here so the - * one-shot survives leaving and returning to this step within one onboarding session. - */ - var inviteCardAnimationPlayed: Boolean = false -} + ) internal class OnboardingInvitePresenter( private val sessionStore: OnboardingSessionStore, @@ -88,6 +83,7 @@ internal class OnboardingInvitePresenter( ): OnboardingInviteUiState { var currentState by remember { mutableStateOf(lastState) } var loadIteration by remember { mutableIntStateOf(0) } + var inviteCardAnimationPlayed by remember { mutableStateOf(false) } LaunchedEffect(loadIteration) { if (currentState is OnboardingInviteUiState.Content) return@LaunchedEffect @@ -115,10 +111,14 @@ internal class OnboardingInvitePresenter( OnboardingInviteEvent.Close -> launch { navigator.exitOnboarding() } OnboardingInviteEvent.Continue -> launch { navigator.continueFrom(OnboardingStepId.InviteFriend) } OnboardingInviteEvent.InviteFriend -> navigator.openForeverScreen() + OnboardingInviteEvent.InviteCardAnimationCompleted -> inviteCardAnimationPlayed = true } } - return currentState + return when (val state = currentState) { + is OnboardingInviteUiState.Content -> state.copy(inviteCardAnimationPlayed = inviteCardAnimationPlayed) + else -> state + } } } @@ -130,6 +130,7 @@ internal sealed interface OnboardingInviteUiState { data class Content( val progress: OnboardingProgress, val incentiveDisplay: String, + val inviteCardAnimationPlayed: Boolean = false, ) : OnboardingInviteUiState } @@ -141,6 +142,8 @@ internal sealed interface OnboardingInviteEvent { data object Continue : OnboardingInviteEvent data object InviteFriend : OnboardingInviteEvent + + data object InviteCardAnimationCompleted : OnboardingInviteEvent } @Composable @@ -172,14 +175,23 @@ internal fun OnboardingInviteDestination(viewModel: OnboardingInviteViewModel, n ) Spacer(Modifier.weight(1f)) Spacer(Modifier.height(24.dp)) - ExampleReferralsCard( - incentiveDisplay = state.incentiveDisplay, - animationAlreadyPlayed = viewModel.inviteCardAnimationPlayed, - onAnimationCompleted = { viewModel.inviteCardAnimationPlayed = true }, + // Outer box takes a fraction of the width so the card keeps side margins in portrait; the + // inner cap keeps it from stretching (and its rows spreading) on wide, e.g. landscape, layouts. + Box( modifier = Modifier .align(Alignment.CenterHorizontally) .fillMaxWidth(0.72f), - ) + contentAlignment = Alignment.Center, + ) { + ExampleReferralsCard( + incentiveDisplay = state.incentiveDisplay, + animationAlreadyPlayed = state.inviteCardAnimationPlayed, + onAnimationCompleted = { viewModel.emit(OnboardingInviteEvent.InviteCardAnimationCompleted) }, + modifier = Modifier + .widthIn(max = 280.dp) + .fillMaxWidth(), + ) + } Spacer(Modifier.weight(1f)) Spacer(Modifier.height(24.dp)) OnboardingStepButtons( From 31a8421c9748721e993e5a824fe87fea0a385255 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 4 Aug 2026 14:24:24 +0200 Subject: [PATCH 084/119] Split the invite step into a stateful destination and previewable screen Extract a stateless OnboardingInviteScreen taking the ui state and event callbacks, leaving OnboardingInviteDestination to only bridge the ViewModel, and add a @HedvigPreview over its loading, error, and content states. This mirrors the stateful/stateless + preview pattern used across the other feature modules. --- .../ui/invite/OnboardingInviteDestination.kt | 79 ++++++++++++++++--- 1 file changed, 69 insertions(+), 10 deletions(-) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt index 635d9b5efc..1b414e9535 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt @@ -27,6 +27,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.hedvig.android.core.common.di.ActivityRetainedScope @@ -34,6 +36,7 @@ import com.hedvig.android.core.common.di.HedvigViewModel import com.hedvig.android.design.system.hedvig.DividerPosition import com.hedvig.android.design.system.hedvig.HedvigErrorSection import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgressDebounced +import com.hedvig.android.design.system.hedvig.HedvigPreview import com.hedvig.android.design.system.hedvig.HedvigText import com.hedvig.android.design.system.hedvig.HedvigTheme import com.hedvig.android.design.system.hedvig.Surface @@ -149,21 +152,44 @@ internal sealed interface OnboardingInviteEvent { @Composable internal fun OnboardingInviteDestination(viewModel: OnboardingInviteViewModel, navigateUp: () -> Unit) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + OnboardingInviteScreen( + uiState = uiState, + progressAnimation = viewModel.progressBarAnimation, + navigateUp = navigateUp, + onClose = { viewModel.emit(OnboardingInviteEvent.Close) }, + onRetry = { viewModel.emit(OnboardingInviteEvent.Retry) }, + onContinue = { viewModel.emit(OnboardingInviteEvent.Continue) }, + onInviteFriend = { viewModel.emit(OnboardingInviteEvent.InviteFriend) }, + onInviteCardAnimationCompleted = { viewModel.emit(OnboardingInviteEvent.InviteCardAnimationCompleted) }, + ) +} + +@Composable +private fun OnboardingInviteScreen( + uiState: OnboardingInviteUiState, + progressAnimation: OnboardingProgressBarAnimation, + navigateUp: () -> Unit, + onClose: () -> Unit, + onRetry: () -> Unit, + onContinue: () -> Unit, + onInviteFriend: () -> Unit, + onInviteCardAnimationCompleted: () -> Unit, +) { OnboardingStepScaffold( progress = (uiState as? OnboardingInviteUiState.Content)?.progress, showBackButton = true, onBackClick = navigateUp, - onCloseClick = { viewModel.emit(OnboardingInviteEvent.Close) }, - progressAnimation = viewModel.progressBarAnimation, + onCloseClick = onClose, + progressAnimation = progressAnimation, ) { - when (val state = uiState) { + when (uiState) { OnboardingInviteUiState.Loading -> { HedvigFullScreenCenterAlignedProgressDebounced() } OnboardingInviteUiState.Error -> { HedvigErrorSection( - onButtonClick = { viewModel.emit(OnboardingInviteEvent.Retry) }, + onButtonClick = onRetry, ) } @@ -171,7 +197,7 @@ internal fun OnboardingInviteDestination(viewModel: OnboardingInviteViewModel, n Spacer(Modifier.height(16.dp)) OnboardingStepHeader( title = stringResource(Res.string.ONBOARDING_INVITE_FRIEND_TITLE), - description = stringResource(Res.string.ONBOARDING_INVITE_FRIEND_SUBTITLE, state.incentiveDisplay), + description = stringResource(Res.string.ONBOARDING_INVITE_FRIEND_SUBTITLE, uiState.incentiveDisplay), ) Spacer(Modifier.weight(1f)) Spacer(Modifier.height(24.dp)) @@ -184,9 +210,9 @@ internal fun OnboardingInviteDestination(viewModel: OnboardingInviteViewModel, n contentAlignment = Alignment.Center, ) { ExampleReferralsCard( - incentiveDisplay = state.incentiveDisplay, - animationAlreadyPlayed = state.inviteCardAnimationPlayed, - onAnimationCompleted = { viewModel.emit(OnboardingInviteEvent.InviteCardAnimationCompleted) }, + incentiveDisplay = uiState.incentiveDisplay, + animationAlreadyPlayed = uiState.inviteCardAnimationPlayed, + onAnimationCompleted = onInviteCardAnimationCompleted, modifier = Modifier .widthIn(max = 280.dp) .fillMaxWidth(), @@ -196,10 +222,10 @@ internal fun OnboardingInviteDestination(viewModel: OnboardingInviteViewModel, n Spacer(Modifier.height(24.dp)) OnboardingStepButtons( primaryText = stringResource(Res.string.general_continue_button), - onPrimaryClick = { viewModel.emit(OnboardingInviteEvent.Continue) }, + onPrimaryClick = onContinue, secondaryText = stringResource(Res.string.ONBOARDING_INVITE_FRIEND_TITLE), secondaryAbovePrimary = true, - onSecondaryClick = { viewModel.emit(OnboardingInviteEvent.InviteFriend) }, + onSecondaryClick = onInviteFriend, ) } } @@ -287,3 +313,36 @@ private fun rowRevealAnimationSpec(): TweenSpec = tween( durationMillis = RowRevealDurationMillis, easing = MotionTokens.EasingEmphasizedDecelerateCubicBezier, ) + +@HedvigPreview +@Composable +private fun PreviewOnboardingInviteScreen( + @PreviewParameter(OnboardingInviteUiStateProvider::class) uiState: OnboardingInviteUiState, +) { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + OnboardingInviteScreen( + uiState = uiState, + progressAnimation = remember { OnboardingProgressBarAnimation() }, + navigateUp = {}, + onClose = {}, + onRetry = {}, + onContinue = {}, + onInviteFriend = {}, + onInviteCardAnimationCompleted = {}, + ) + } + } +} + +private class OnboardingInviteUiStateProvider : CollectionPreviewParameterProvider( + listOf( + OnboardingInviteUiState.Loading, + OnboardingInviteUiState.Error, + OnboardingInviteUiState.Content( + progress = OnboardingProgress(totalSteps = 5, currentIndex = 4), + incentiveDisplay = "10 kr", + inviteCardAnimationPlayed = true, + ), + ), +) From 271129e4caa640335cde7ff262243da168edeee3 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 4 Aug 2026 15:08:44 +0200 Subject: [PATCH 085/119] Split welcome, payment, consent, and bundle steps into previewable screens Extract a stateless OnboardingScreen taking ui state and event callbacks from each destination, leaving the destination to bridge the ViewModel and hold the side effects (payment's LifecycleResumeEffect stays in the wrapper), and add a @HedvigPreview over each screen's states. Matches the theme and invite steps. --- .../ui/bundle/OnboardingBundleDestination.kt | 83 ++++++++++++++++++- .../consent/OnboardingConsentDestination.kt | 68 +++++++++++++-- .../payment/OnboardingPaymentDestination.kt | 74 +++++++++++++++-- .../welcome/OnboardingWelcomeDestination.kt | 57 ++++++++++++- 4 files changed, 264 insertions(+), 18 deletions(-) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt index f05f7846dd..82bc6da60d 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt @@ -20,6 +20,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.ImageLoader @@ -30,9 +32,12 @@ import com.hedvig.android.design.system.hedvig.ButtonDefaults import com.hedvig.android.design.system.hedvig.HedvigButton import com.hedvig.android.design.system.hedvig.HedvigErrorSection import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgressDebounced +import com.hedvig.android.design.system.hedvig.HedvigPreview import com.hedvig.android.design.system.hedvig.HedvigText import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.Surface import com.hedvig.android.design.system.hedvig.placeholder.crossSellPainterFallback +import com.hedvig.android.design.system.hedvig.rememberPreviewImageLoader import com.hedvig.android.feature.onboarding.data.OnboardingCrossSell import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator @@ -131,12 +136,35 @@ internal fun OnboardingBundleDestination( openUrl: (String) -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + OnboardingBundleScreen( + uiState = uiState, + progressAnimation = viewModel.progressBarAnimation, + imageLoader = imageLoader, + navigateUp = navigateUp, + onClose = { viewModel.emit(OnboardingBundleEvent.Close) }, + onRetry = { viewModel.emit(OnboardingBundleEvent.Retry) }, + onContinueToApp = { viewModel.emit(OnboardingBundleEvent.ContinueToApp) }, + openUrl = openUrl, + ) +} + +@Composable +private fun OnboardingBundleScreen( + uiState: OnboardingBundleUiState, + progressAnimation: OnboardingProgressBarAnimation, + imageLoader: ImageLoader, + navigateUp: () -> Unit, + onClose: () -> Unit, + onRetry: () -> Unit, + onContinueToApp: () -> Unit, + openUrl: (String) -> Unit, +) { OnboardingStepScaffold( progress = (uiState as? OnboardingBundleUiState.Content)?.progress, showBackButton = true, onBackClick = navigateUp, - onCloseClick = { viewModel.emit(OnboardingBundleEvent.Close) }, - progressAnimation = viewModel.progressBarAnimation, + onCloseClick = onClose, + progressAnimation = progressAnimation, ) { when (val state = uiState) { OnboardingBundleUiState.Loading -> { @@ -145,7 +173,7 @@ internal fun OnboardingBundleDestination( OnboardingBundleUiState.Error -> { HedvigErrorSection( - onButtonClick = { viewModel.emit(OnboardingBundleEvent.Retry) }, + onButtonClick = onRetry, ) } @@ -181,7 +209,7 @@ internal fun OnboardingBundleDestination( Spacer(Modifier.height(24.dp)) OnboardingStepButtons( primaryText = stringResource(Res.string.ONBOARDING_CONTINUE_TO_APP_BUTTON), - onPrimaryClick = { viewModel.emit(OnboardingBundleEvent.ContinueToApp) }, + onPrimaryClick = onContinueToApp, ) } } @@ -233,3 +261,50 @@ private fun OnboardingCrossSellRow( ) } } + +@HedvigPreview +@Composable +private fun PreviewOnboardingBundleScreen( + @PreviewParameter(OnboardingBundleUiStateProvider::class) uiState: OnboardingBundleUiState, +) { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + OnboardingBundleScreen( + uiState = uiState, + progressAnimation = remember { OnboardingProgressBarAnimation() }, + imageLoader = rememberPreviewImageLoader(), + navigateUp = {}, + onClose = {}, + onRetry = {}, + onContinueToApp = {}, + openUrl = {}, + ) + } + } +} + +private class OnboardingBundleUiStateProvider : CollectionPreviewParameterProvider( + listOf( + OnboardingBundleUiState.Loading, + OnboardingBundleUiState.Error, + OnboardingBundleUiState.Content( + progress = OnboardingProgress(totalSteps = 5, currentIndex = 1), + crossSells = listOf( + OnboardingCrossSell( + id = "accident", + title = "Accident Insurance", + description = "Coverage for accidental injuries", + storeUrl = "https://www.hedvig.com/se/forsakringar/olycksfallsforsakring", + pillowImageUrl = null, + ), + OnboardingCrossSell( + id = "pet", + title = "Pet Insurance", + description = "Coverage for your pet", + storeUrl = "https://www.hedvig.com/se/forsakringar/djurforsakring", + pillowImageUrl = null, + ), + ), + ), + ), +) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingConsentDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingConsentDestination.kt index 08452c1ac6..4e2bba8d7c 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingConsentDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingConsentDestination.kt @@ -24,6 +24,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.hedvig.android.core.common.di.ActivityRetainedScope @@ -34,9 +36,11 @@ import com.hedvig.android.design.system.hedvig.ButtonDefaults import com.hedvig.android.design.system.hedvig.HedvigButton import com.hedvig.android.design.system.hedvig.HedvigErrorSection import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgressDebounced +import com.hedvig.android.design.system.hedvig.HedvigPreview import com.hedvig.android.design.system.hedvig.HedvigText import com.hedvig.android.design.system.hedvig.HedvigTheme import com.hedvig.android.design.system.hedvig.Icon +import com.hedvig.android.design.system.hedvig.Surface import com.hedvig.android.design.system.hedvig.icon.ArrowNorthEast import com.hedvig.android.design.system.hedvig.icon.HedvigIcons import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore @@ -142,12 +146,35 @@ internal fun OnboardingConsentDestination( openPrivacyPolicy: () -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + OnboardingConsentScreen( + uiState = uiState, + progressAnimation = viewModel.progressBarAnimation, + navigateUp = navigateUp, + openPrivacyPolicy = openPrivacyPolicy, + onClose = { viewModel.emit(OnboardingConsentEvent.Close) }, + onRetry = { viewModel.emit(OnboardingConsentEvent.Retry) }, + onAllow = { viewModel.emit(OnboardingConsentEvent.Allow) }, + onDeny = { viewModel.emit(OnboardingConsentEvent.Deny) }, + ) +} + +@Composable +private fun OnboardingConsentScreen( + uiState: OnboardingConsentUiState, + progressAnimation: OnboardingProgressBarAnimation, + navigateUp: () -> Unit, + openPrivacyPolicy: () -> Unit, + onClose: () -> Unit, + onRetry: () -> Unit, + onAllow: () -> Unit, + onDeny: () -> Unit, +) { OnboardingStepScaffold( progress = (uiState as? OnboardingConsentUiState.Content)?.progress, showBackButton = true, onBackClick = navigateUp, - onCloseClick = { viewModel.emit(OnboardingConsentEvent.Close) }, - progressAnimation = viewModel.progressBarAnimation, + onCloseClick = onClose, + progressAnimation = progressAnimation, ) { when (uiState) { OnboardingConsentUiState.Loading -> { @@ -156,7 +183,7 @@ internal fun OnboardingConsentDestination( OnboardingConsentUiState.Error -> { HedvigErrorSection( - onButtonClick = { viewModel.emit(OnboardingConsentEvent.Retry) }, + onButtonClick = onRetry, ) } @@ -193,7 +220,7 @@ internal fun OnboardingConsentDestination( Spacer(Modifier.height(16.dp)) HedvigButton( text = stringResource(Res.string.ONBOARDING_ANALYTICS_ALLOW_BUTTON), - onClick = { viewModel.emit(OnboardingConsentEvent.Allow) }, + onClick = onAllow, enabled = true, buttonStyle = ButtonDefaults.ButtonStyle.Secondary, modifier = Modifier @@ -204,7 +231,7 @@ internal fun OnboardingConsentDestination( Spacer(Modifier.height(8.dp)) HedvigButton( text = stringResource(Res.string.ONBOARDING_ANALYTICS_DENY_BUTTON), - onClick = { viewModel.emit(OnboardingConsentEvent.Deny) }, + onClick = onDeny, enabled = true, buttonStyle = ButtonDefaults.ButtonStyle.Secondary, modifier = Modifier @@ -218,3 +245,34 @@ internal fun OnboardingConsentDestination( } } } + +@HedvigPreview +@Composable +private fun PreviewOnboardingConsentScreen( + @PreviewParameter(OnboardingConsentUiStateProvider::class) uiState: OnboardingConsentUiState, +) { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + OnboardingConsentScreen( + uiState = uiState, + progressAnimation = remember { OnboardingProgressBarAnimation() }, + navigateUp = {}, + openPrivacyPolicy = {}, + onClose = {}, + onRetry = {}, + onAllow = {}, + onDeny = {}, + ) + } + } +} + +private class OnboardingConsentUiStateProvider : CollectionPreviewParameterProvider( + listOf( + OnboardingConsentUiState.Loading, + OnboardingConsentUiState.Error, + OnboardingConsentUiState.Content( + progress = OnboardingProgress(totalSteps = 5, currentIndex = 2), + ), + ), +) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt index 7493c7b131..23b0ea068f 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt @@ -13,6 +13,8 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -20,8 +22,10 @@ import com.hedvig.android.core.common.di.ActivityRetainedScope import com.hedvig.android.core.common.di.HedvigViewModel import com.hedvig.android.design.system.hedvig.HedvigErrorSection import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgressDebounced +import com.hedvig.android.design.system.hedvig.HedvigPreview import com.hedvig.android.design.system.hedvig.HedvigText import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.Surface import com.hedvig.android.feature.onboarding.data.OnboardingPayinStatus import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator @@ -143,12 +147,33 @@ internal fun OnboardingPaymentDestination(viewModel: OnboardingPaymentViewModel, onPauseOrDispose {} } + OnboardingPaymentScreen( + uiState = uiState, + progressAnimation = viewModel.progressBarAnimation, + navigateUp = navigateUp, + onClose = { viewModel.emit(OnboardingPaymentEvent.Close) }, + onRetry = { viewModel.emit(OnboardingPaymentEvent.Retry) }, + onConnectPayment = { viewModel.emit(OnboardingPaymentEvent.ConnectPayment) }, + onContinue = { viewModel.emit(OnboardingPaymentEvent.Continue) }, + ) +} + +@Composable +private fun OnboardingPaymentScreen( + uiState: OnboardingPaymentUiState, + progressAnimation: OnboardingProgressBarAnimation, + navigateUp: () -> Unit, + onClose: () -> Unit, + onRetry: () -> Unit, + onConnectPayment: () -> Unit, + onContinue: () -> Unit, +) { OnboardingStepScaffold( progress = (uiState as? OnboardingPaymentUiState.Content)?.progress, showBackButton = true, onBackClick = navigateUp, - onCloseClick = { viewModel.emit(OnboardingPaymentEvent.Close) }, - progressAnimation = viewModel.progressBarAnimation, + onCloseClick = onClose, + progressAnimation = progressAnimation, ) { when (val content = uiState) { OnboardingPaymentUiState.Loading -> { @@ -157,7 +182,7 @@ internal fun OnboardingPaymentDestination(viewModel: OnboardingPaymentViewModel, OnboardingPaymentUiState.Error -> { HedvigErrorSection( - onButtonClick = { viewModel.emit(OnboardingPaymentEvent.Retry) }, + onButtonClick = onRetry, ) } @@ -181,7 +206,7 @@ internal fun OnboardingPaymentDestination(viewModel: OnboardingPaymentViewModel, ) OnboardingStepButtons( primaryText = stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_TITLE), - onPrimaryClick = { viewModel.emit(OnboardingPaymentEvent.ConnectPayment) }, + onPrimaryClick = onConnectPayment, ) } @@ -200,7 +225,7 @@ internal fun OnboardingPaymentDestination(viewModel: OnboardingPaymentViewModel, ) OnboardingStepButtons( primaryText = stringResource(Res.string.general_continue_button), - onPrimaryClick = { viewModel.emit(OnboardingPaymentEvent.Continue) }, + onPrimaryClick = onContinue, ) } } @@ -208,3 +233,42 @@ internal fun OnboardingPaymentDestination(viewModel: OnboardingPaymentViewModel, } } } + +@HedvigPreview +@Composable +private fun PreviewOnboardingPaymentScreen( + @PreviewParameter(OnboardingPaymentUiStateProvider::class) uiState: OnboardingPaymentUiState, +) { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + OnboardingPaymentScreen( + uiState = uiState, + progressAnimation = remember { OnboardingProgressBarAnimation() }, + navigateUp = {}, + onClose = {}, + onRetry = {}, + onConnectPayment = {}, + onContinue = {}, + ) + } + } +} + +private class OnboardingPaymentUiStateProvider : CollectionPreviewParameterProvider( + listOf( + OnboardingPaymentUiState.Loading, + OnboardingPaymentUiState.Error, + OnboardingPaymentUiState.Content( + progress = OnboardingProgress(totalSteps = 5, currentIndex = 3), + payinStatus = OnboardingPayinStatus.NeedsSetup, + ), + OnboardingPaymentUiState.Content( + progress = OnboardingProgress(totalSteps = 5, currentIndex = 3), + payinStatus = OnboardingPayinStatus.Pending, + ), + OnboardingPaymentUiState.Content( + progress = OnboardingProgress(totalSteps = 5, currentIndex = 3), + payinStatus = OnboardingPayinStatus.Active, + ), + ), +) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt index 55e37924be..b97fd252f4 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt @@ -16,15 +16,19 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.LineBreak import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.hedvig.android.core.common.di.ActivityRetainedScope import com.hedvig.android.core.common.di.HedvigViewModel import com.hedvig.android.design.system.hedvig.HedvigErrorSection import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgressDebounced +import com.hedvig.android.design.system.hedvig.HedvigPreview import com.hedvig.android.design.system.hedvig.HedvigText import com.hedvig.android.design.system.hedvig.HedvigTheme import com.hedvig.android.design.system.hedvig.LocalTextStyle +import com.hedvig.android.design.system.hedvig.Surface import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator import com.hedvig.android.feature.onboarding.ui.OnboardingProgress @@ -107,12 +111,29 @@ internal sealed interface OnboardingWelcomeEvent { @Composable internal fun OnboardingWelcomeDestination(viewModel: OnboardingWelcomeViewModel) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + OnboardingWelcomeScreen( + uiState = uiState, + progressAnimation = viewModel.progressBarAnimation, + onClose = { viewModel.emit(OnboardingWelcomeEvent.Close) }, + onRetry = { viewModel.emit(OnboardingWelcomeEvent.Retry) }, + onGetStarted = { viewModel.emit(OnboardingWelcomeEvent.GetStarted) }, + ) +} + +@Composable +private fun OnboardingWelcomeScreen( + uiState: OnboardingWelcomeUiState, + progressAnimation: OnboardingProgressBarAnimation, + onClose: () -> Unit, + onRetry: () -> Unit, + onGetStarted: () -> Unit, +) { OnboardingStepScaffold( progress = (uiState as? OnboardingWelcomeUiState.Content)?.progress, showBackButton = false, onBackClick = {}, - onCloseClick = { viewModel.emit(OnboardingWelcomeEvent.Close) }, - progressAnimation = viewModel.progressBarAnimation, + onCloseClick = onClose, + progressAnimation = progressAnimation, ) { when (uiState) { OnboardingWelcomeUiState.Loading -> { @@ -121,7 +142,7 @@ internal fun OnboardingWelcomeDestination(viewModel: OnboardingWelcomeViewModel) OnboardingWelcomeUiState.Error -> { HedvigErrorSection( - onButtonClick = { viewModel.emit(OnboardingWelcomeEvent.Retry) }, + onButtonClick = onRetry, ) } @@ -153,9 +174,37 @@ internal fun OnboardingWelcomeDestination(viewModel: OnboardingWelcomeViewModel) Spacer(Modifier.height(24.dp)) OnboardingStepButtons( primaryText = stringResource(Res.string.ONBOARDING_WELCOME_BUTTON), - onPrimaryClick = { viewModel.emit(OnboardingWelcomeEvent.GetStarted) }, + onPrimaryClick = onGetStarted, ) } } } } + +@HedvigPreview +@Composable +private fun PreviewOnboardingWelcomeScreen( + @PreviewParameter(OnboardingWelcomeUiStateProvider::class) uiState: OnboardingWelcomeUiState, +) { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + OnboardingWelcomeScreen( + uiState = uiState, + progressAnimation = remember { OnboardingProgressBarAnimation() }, + onClose = {}, + onRetry = {}, + onGetStarted = {}, + ) + } + } +} + +private class OnboardingWelcomeUiStateProvider : CollectionPreviewParameterProvider( + listOf( + OnboardingWelcomeUiState.Loading, + OnboardingWelcomeUiState.Error, + OnboardingWelcomeUiState.Content( + progress = OnboardingProgress(totalSteps = 5, currentIndex = 1), + ), + ), +) From 5d44eade0f09ce69598cfb68054319d852972e73 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 4 Aug 2026 15:12:31 +0200 Subject: [PATCH 086/119] Split pet-id and co-insured steps into previewable screens Extract a stateless OnboardingScreen from each destination, keeping the refresh LifecycleResumeEffect in the stateful wrapper, and add a @HedvigPreview covering loading, error, and content (co-insured includes an all-co-owners variant to exercise the header logic). --- .../OnboardingCoInsuredDestination.kt | 111 +++++++++++++++++- .../ui/petid/OnboardingPetIdDestination.kt | 81 ++++++++++++- 2 files changed, 181 insertions(+), 11 deletions(-) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt index 7c69f1c0bd..b1deb27132 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt @@ -16,6 +16,8 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -24,8 +26,10 @@ import com.hedvig.android.core.common.di.HedvigViewModel import com.hedvig.android.data.coinsured.CoInsuredFlowType import com.hedvig.android.design.system.hedvig.HedvigErrorSection import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgressDebounced +import com.hedvig.android.design.system.hedvig.HedvigPreview import com.hedvig.android.design.system.hedvig.HedvigText import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.Surface import com.hedvig.android.feature.onboarding.data.OnboardingSession import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator @@ -175,12 +179,38 @@ internal fun OnboardingCoInsuredDestination(viewModel: OnboardingCoInsuredViewMo onPauseOrDispose {} } + OnboardingCoInsuredScreen( + uiState = uiState, + progressAnimation = viewModel.progressBarAnimation, + navigateUp = navigateUp, + onClose = { viewModel.emit(OnboardingCoInsuredEvent.Close) }, + onRetry = { viewModel.emit(OnboardingCoInsuredEvent.Retry) }, + onAddCoInsured = { + contractId, + flowType, + -> + viewModel.emit(OnboardingCoInsuredEvent.AddCoInsured(contractId, flowType)) + }, + onContinue = { viewModel.emit(OnboardingCoInsuredEvent.Continue) }, + ) +} + +@Composable +private fun OnboardingCoInsuredScreen( + uiState: OnboardingCoInsuredUiState, + progressAnimation: OnboardingProgressBarAnimation, + navigateUp: () -> Unit, + onClose: () -> Unit, + onRetry: () -> Unit, + onAddCoInsured: (contractId: String, flowType: CoInsuredFlowType) -> Unit, + onContinue: () -> Unit, +) { OnboardingStepScaffold( progress = (uiState as? OnboardingCoInsuredUiState.Content)?.progress, showBackButton = true, onBackClick = navigateUp, - onCloseClick = { viewModel.emit(OnboardingCoInsuredEvent.Close) }, - progressAnimation = viewModel.progressBarAnimation, + onCloseClick = onClose, + progressAnimation = progressAnimation, ) { when (val content = uiState) { OnboardingCoInsuredUiState.Loading -> { @@ -189,7 +219,7 @@ internal fun OnboardingCoInsuredDestination(viewModel: OnboardingCoInsuredViewMo OnboardingCoInsuredUiState.Error -> { HedvigErrorSection( - onButtonClick = { viewModel.emit(OnboardingCoInsuredEvent.Retry) }, + onButtonClick = onRetry, ) } @@ -215,7 +245,7 @@ internal fun OnboardingCoInsuredDestination(viewModel: OnboardingCoInsuredViewMo exposureName = row.exposureName, typeOfContract = row.typeOfContract, isComplete = row.isComplete, - onAddClick = { viewModel.emit(OnboardingCoInsuredEvent.AddCoInsured(row.contractId, row.flowType)) }, + onAddClick = { onAddCoInsured(row.contractId, row.flowType) }, ) } } @@ -232,11 +262,80 @@ internal fun OnboardingCoInsuredDestination(viewModel: OnboardingCoInsuredViewMo ) OnboardingStepButtons( primaryText = stringResource(Res.string.general_continue_button), - onPrimaryClick = { viewModel.emit(OnboardingCoInsuredEvent.Continue) }, + onPrimaryClick = onContinue, secondaryText = stringResource(Res.string.ONBOARDING_DO_THIS_LATER_BUTTON), - onSecondaryClick = { viewModel.emit(OnboardingCoInsuredEvent.Continue) }, + onSecondaryClick = onContinue, ) } } } } + +@HedvigPreview +@Composable +private fun PreviewOnboardingCoInsuredScreen( + @PreviewParameter(OnboardingCoInsuredUiStateProvider::class) uiState: OnboardingCoInsuredUiState, +) { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + OnboardingCoInsuredScreen( + uiState = uiState, + progressAnimation = remember { OnboardingProgressBarAnimation() }, + navigateUp = {}, + onClose = {}, + onRetry = {}, + onAddCoInsured = { _, _ -> }, + onContinue = {}, + ) + } + } +} + +private class OnboardingCoInsuredUiStateProvider : CollectionPreviewParameterProvider( + listOf( + OnboardingCoInsuredUiState.Loading, + OnboardingCoInsuredUiState.Error, + OnboardingCoInsuredUiState.Content( + progress = OnboardingProgress(totalSteps = 5, currentIndex = 2), + rows = listOf( + CoInsuredRow( + contractId = "contract-1", + displayName = "Home Insurance", + exposureName = "Storgatan 1", + typeOfContract = "SE_APARTMENT_RENT", + flowType = CoInsuredFlowType.CoInsured, + isComplete = false, + ), + CoInsuredRow( + contractId = "contract-2", + displayName = "Car Insurance", + exposureName = "ABC 123", + typeOfContract = "SE_CAR_FULL", + flowType = CoInsuredFlowType.CoInsured, + isComplete = true, + ), + ), + ), + OnboardingCoInsuredUiState.Content( + progress = OnboardingProgress(totalSteps = 5, currentIndex = 2), + rows = listOf( + CoInsuredRow( + contractId = "contract-3", + displayName = "Villa Insurance", + exposureName = "Villavägen 5", + typeOfContract = "SE_VILLA", + flowType = CoInsuredFlowType.CoOwners, + isComplete = false, + ), + CoInsuredRow( + contractId = "contract-4", + displayName = "Cottage Insurance", + exposureName = "Sommarvägen 12", + typeOfContract = "SE_VILLA", + flowType = CoInsuredFlowType.CoOwners, + isComplete = false, + ), + ), + ), + ), +) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdDestination.kt index ffbdad1bf4..4b8013c69e 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdDestination.kt @@ -16,6 +16,8 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -23,8 +25,10 @@ import com.hedvig.android.core.common.di.ActivityRetainedScope import com.hedvig.android.core.common.di.HedvigViewModel import com.hedvig.android.design.system.hedvig.HedvigErrorSection import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgressDebounced +import com.hedvig.android.design.system.hedvig.HedvigPreview import com.hedvig.android.design.system.hedvig.HedvigText import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.Surface import com.hedvig.android.feature.onboarding.data.OnboardingSession import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator @@ -168,12 +172,33 @@ internal fun OnboardingPetIdDestination( onPauseOrDispose {} } + OnboardingPetIdScreen( + uiState = uiState, + progressAnimation = viewModel.progressBarAnimation, + navigateUp = navigateUp, + onClose = { viewModel.emit(OnboardingPetIdEvent.Close) }, + onRetry = { viewModel.emit(OnboardingPetIdEvent.Retry) }, + onContinue = { viewModel.emit(OnboardingPetIdEvent.Continue) }, + onAddPetId = onAddPetId, + ) +} + +@Composable +private fun OnboardingPetIdScreen( + uiState: OnboardingPetIdUiState, + progressAnimation: OnboardingProgressBarAnimation, + navigateUp: () -> Unit, + onClose: () -> Unit, + onRetry: () -> Unit, + onContinue: () -> Unit, + onAddPetId: (contractId: String) -> Unit, +) { OnboardingStepScaffold( progress = (uiState as? OnboardingPetIdUiState.Content)?.progress, showBackButton = true, onBackClick = navigateUp, - onCloseClick = { viewModel.emit(OnboardingPetIdEvent.Close) }, - progressAnimation = viewModel.progressBarAnimation, + onCloseClick = onClose, + progressAnimation = progressAnimation, ) { when (val content = uiState) { OnboardingPetIdUiState.Loading -> { @@ -182,7 +207,7 @@ internal fun OnboardingPetIdDestination( OnboardingPetIdUiState.Error -> { HedvigErrorSection( - onButtonClick = { viewModel.emit(OnboardingPetIdEvent.Retry) }, + onButtonClick = onRetry, ) } @@ -218,11 +243,57 @@ internal fun OnboardingPetIdDestination( ) OnboardingStepButtons( primaryText = stringResource(Res.string.general_continue_button), - onPrimaryClick = { viewModel.emit(OnboardingPetIdEvent.Continue) }, + onPrimaryClick = onContinue, secondaryText = stringResource(Res.string.ONBOARDING_DO_THIS_LATER_BUTTON), - onSecondaryClick = { viewModel.emit(OnboardingPetIdEvent.Continue) }, + onSecondaryClick = onContinue, ) } } } } + +@HedvigPreview +@Composable +private fun PreviewOnboardingPetIdScreen( + @PreviewParameter(OnboardingPetIdUiStateProvider::class) uiState: OnboardingPetIdUiState, +) { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + OnboardingPetIdScreen( + uiState = uiState, + progressAnimation = remember { OnboardingProgressBarAnimation() }, + navigateUp = {}, + onClose = {}, + onRetry = {}, + onContinue = {}, + onAddPetId = {}, + ) + } + } +} + +private class OnboardingPetIdUiStateProvider : CollectionPreviewParameterProvider( + listOf( + OnboardingPetIdUiState.Loading, + OnboardingPetIdUiState.Error, + OnboardingPetIdUiState.Content( + progress = OnboardingProgress(totalSteps = 5, currentIndex = 2), + rows = listOf( + PetIdRow( + contractId = "contract-1", + displayName = "Hedvig Pet", + exposureName = "Fido", + typeOfContract = "SE_CAT_BASIC", + isComplete = false, + ), + PetIdRow( + contractId = "contract-2", + displayName = "Hedvig Pet", + exposureName = "Whiskers", + typeOfContract = "SE_CAT_BASIC", + isComplete = true, + ), + ), + ), + ), +) From 9bb0f8092b802b01c5781d05d3c48d7817874d8f Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 4 Aug 2026 15:17:22 +0200 Subject: [PATCH 087/119] Extract onboarding ViewModels and presenters into their own files Move the ViewModel, Presenter, UiState, and Event out of the invite, theme, phone, pet-id, and co-insured destination files into sibling OnboardingViewModel.kt files, leaving each destination file as UI. This matches the repo-wide one-ViewModel-per-file convention and keeps the larger steps' files focused. --- .../OnboardingCoInsuredDestination.kt | 110 ------------ .../coinsured/OnboardingCoInsuredViewModel.kt | 166 ++++++++++++++++++ .../ui/invite/OnboardingInviteDestination.kt | 84 --------- .../ui/invite/OnboardingInviteViewModel.kt | 150 ++++++++++++++++ .../ui/petid/OnboardingPetIdDestination.kt | 101 ----------- .../ui/petid/OnboardingPetIdViewModel.kt | 155 ++++++++++++++++ .../ui/phone/OnboardingPhoneDestination.kt | 116 ------------ .../ui/phone/OnboardingPhoneViewModel.kt | 166 ++++++++++++++++++ .../ui/theme/OnboardingThemeDestination.kt | 81 --------- .../ui/theme/OnboardingThemeViewModel.kt | 152 ++++++++++++++++ 10 files changed, 789 insertions(+), 492 deletions(-) create mode 100644 app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredViewModel.kt create mode 100644 app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteViewModel.kt create mode 100644 app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdViewModel.kt create mode 100644 app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneViewModel.kt create mode 100644 app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeViewModel.kt diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt index b1deb27132..6c9d731136 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt @@ -55,116 +55,6 @@ import hedvig.resources.general_continue_button import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource -@Inject -@HedvigViewModel(ActivityRetainedScope::class) -internal class OnboardingCoInsuredViewModel( - sessionStore: OnboardingSessionStore, - navigator: OnboardingNavigator, - val progressBarAnimation: OnboardingProgressBarAnimation, -) : MoleculeViewModel( - initialState = OnboardingCoInsuredUiState.Loading, - presenter = OnboardingCoInsuredPresenter(sessionStore, navigator), - ) - -internal class OnboardingCoInsuredPresenter( - private val sessionStore: OnboardingSessionStore, - private val navigator: OnboardingNavigator, -) : MoleculePresenter { - @Composable - override fun MoleculePresenterScope.present( - lastState: OnboardingCoInsuredUiState, - ): OnboardingCoInsuredUiState { - var currentState by remember { mutableStateOf(lastState) } - var loadIteration by remember { mutableIntStateOf(0) } - - // Pin which contracts belong to this step (and each one's flow type) the first time data is - // available, so completed rows render as done instead of disappearing and the header title - // stays stable even after a contract's missing info is filled in. - var pinnedContracts by remember { mutableStateOf>?>(null) } - - fun contentFrom(session: OnboardingSession): OnboardingCoInsuredUiState.Content { - val pinned = pinnedContracts - ?: session.data.contractsMissingInsuredOrOwnerInfo - .mapNotNull { contract -> contract.coInsuredFlowType?.let { contract.id to it } } - .also { pinnedContracts = it } - return OnboardingCoInsuredUiState.Content( - progress = session.progressFor(OnboardingStepId.CoInsured), - rows = pinned.mapNotNull { (id, flowType) -> - val contract = session.data.contracts.firstOrNull { it.id == id } ?: return@mapNotNull null - CoInsuredRow( - contractId = contract.id, - displayName = contract.displayName, - exposureName = contract.exposureName, - typeOfContract = contract.typeOfContract, - flowType = flowType, - isComplete = contract.coInsuredFlowType == null, - ) - }, - ) - } - - LaunchedEffect(loadIteration) { - if (currentState is OnboardingCoInsuredUiState.Content) return@LaunchedEffect - currentState = OnboardingCoInsuredUiState.Loading - sessionStore.getOrFetchSession().fold( - ifLeft = { currentState = OnboardingCoInsuredUiState.Error }, - ifRight = { session -> currentState = contentFrom(session) }, - ) - } - - CollectEvents { event -> - when (event) { - OnboardingCoInsuredEvent.Retry -> loadIteration++ - - OnboardingCoInsuredEvent.Close -> launch { navigator.exitOnboarding() } - - OnboardingCoInsuredEvent.Refresh -> launch { - sessionStore.refreshData().onRight { refreshed -> currentState = contentFrom(refreshed) } - // onLeft: keep the previously shown state, per the spec's error handling. - } - - is OnboardingCoInsuredEvent.AddCoInsured -> navigator.openAddCoInsured(event.contractId, event.flowType) - - OnboardingCoInsuredEvent.Continue -> launch { navigator.continueFrom(OnboardingStepId.CoInsured) } - } - } - - return currentState - } -} - -internal data class CoInsuredRow( - val contractId: String, - val displayName: String, - val exposureName: String, - val typeOfContract: String, - val flowType: CoInsuredFlowType, - val isComplete: Boolean, -) - -internal sealed interface OnboardingCoInsuredUiState { - data object Loading : OnboardingCoInsuredUiState - - data object Error : OnboardingCoInsuredUiState - - data class Content( - val progress: OnboardingProgress, - val rows: List, - ) : OnboardingCoInsuredUiState -} - -internal sealed interface OnboardingCoInsuredEvent { - data object Retry : OnboardingCoInsuredEvent - - data object Close : OnboardingCoInsuredEvent - - data object Refresh : OnboardingCoInsuredEvent - - data class AddCoInsured(val contractId: String, val flowType: CoInsuredFlowType) : OnboardingCoInsuredEvent - - data object Continue : OnboardingCoInsuredEvent -} - @Composable internal fun OnboardingCoInsuredDestination(viewModel: OnboardingCoInsuredViewModel, navigateUp: () -> Unit) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredViewModel.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredViewModel.kt new file mode 100644 index 0000000000..b959e2a429 --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredViewModel.kt @@ -0,0 +1,166 @@ +package com.hedvig.android.feature.onboarding.ui.coinsured + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.hedvig.android.core.common.di.ActivityRetainedScope +import com.hedvig.android.core.common.di.HedvigViewModel +import com.hedvig.android.data.coinsured.CoInsuredFlowType +import com.hedvig.android.design.system.hedvig.HedvigErrorSection +import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgressDebounced +import com.hedvig.android.design.system.hedvig.HedvigPreview +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.Surface +import com.hedvig.android.feature.onboarding.data.OnboardingSession +import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore +import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator +import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId +import com.hedvig.android.feature.onboarding.ui.OnboardingContractCard +import com.hedvig.android.feature.onboarding.ui.OnboardingProgress +import com.hedvig.android.feature.onboarding.ui.OnboardingProgressBarAnimation +import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons +import com.hedvig.android.feature.onboarding.ui.OnboardingStepHeader +import com.hedvig.android.feature.onboarding.ui.OnboardingStepScaffold +import com.hedvig.android.feature.onboarding.ui.progressFor +import com.hedvig.android.molecule.public.MoleculePresenter +import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.molecule.public.MoleculeViewModel +import dev.zacsweers.metro.Inject +import hedvig.resources.ONBOARDING_ADD_COINSURED_TITLE +import hedvig.resources.ONBOARDING_ADD_COOWNERS_TITLE +import hedvig.resources.ONBOARDING_ADD_INFO_LATER_LABEL +import hedvig.resources.ONBOARDING_DO_THIS_LATER_BUTTON +import hedvig.resources.ONBOARDING_MISSING_INFO_SUBTITLE +import hedvig.resources.Res +import hedvig.resources.general_continue_button +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource + +@Inject +@HedvigViewModel(ActivityRetainedScope::class) +internal class OnboardingCoInsuredViewModel( + sessionStore: OnboardingSessionStore, + navigator: OnboardingNavigator, + val progressBarAnimation: OnboardingProgressBarAnimation, +) : MoleculeViewModel( + initialState = OnboardingCoInsuredUiState.Loading, + presenter = OnboardingCoInsuredPresenter(sessionStore, navigator), + ) + +internal class OnboardingCoInsuredPresenter( + private val sessionStore: OnboardingSessionStore, + private val navigator: OnboardingNavigator, +) : MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present( + lastState: OnboardingCoInsuredUiState, + ): OnboardingCoInsuredUiState { + var currentState by remember { mutableStateOf(lastState) } + var loadIteration by remember { mutableIntStateOf(0) } + + // Pin which contracts belong to this step (and each one's flow type) the first time data is + // available, so completed rows render as done instead of disappearing and the header title + // stays stable even after a contract's missing info is filled in. + var pinnedContracts by remember { mutableStateOf>?>(null) } + + fun contentFrom(session: OnboardingSession): OnboardingCoInsuredUiState.Content { + val pinned = pinnedContracts + ?: session.data.contractsMissingInsuredOrOwnerInfo + .mapNotNull { contract -> contract.coInsuredFlowType?.let { contract.id to it } } + .also { pinnedContracts = it } + return OnboardingCoInsuredUiState.Content( + progress = session.progressFor(OnboardingStepId.CoInsured), + rows = pinned.mapNotNull { (id, flowType) -> + val contract = session.data.contracts.firstOrNull { it.id == id } ?: return@mapNotNull null + CoInsuredRow( + contractId = contract.id, + displayName = contract.displayName, + exposureName = contract.exposureName, + typeOfContract = contract.typeOfContract, + flowType = flowType, + isComplete = contract.coInsuredFlowType == null, + ) + }, + ) + } + + LaunchedEffect(loadIteration) { + if (currentState is OnboardingCoInsuredUiState.Content) return@LaunchedEffect + currentState = OnboardingCoInsuredUiState.Loading + sessionStore.getOrFetchSession().fold( + ifLeft = { currentState = OnboardingCoInsuredUiState.Error }, + ifRight = { session -> currentState = contentFrom(session) }, + ) + } + + CollectEvents { event -> + when (event) { + OnboardingCoInsuredEvent.Retry -> loadIteration++ + + OnboardingCoInsuredEvent.Close -> launch { navigator.exitOnboarding() } + + OnboardingCoInsuredEvent.Refresh -> launch { + sessionStore.refreshData().onRight { refreshed -> currentState = contentFrom(refreshed) } + // onLeft: keep the previously shown state, per the spec's error handling. + } + + is OnboardingCoInsuredEvent.AddCoInsured -> navigator.openAddCoInsured(event.contractId, event.flowType) + + OnboardingCoInsuredEvent.Continue -> launch { navigator.continueFrom(OnboardingStepId.CoInsured) } + } + } + + return currentState + } +} + +internal data class CoInsuredRow( + val contractId: String, + val displayName: String, + val exposureName: String, + val typeOfContract: String, + val flowType: CoInsuredFlowType, + val isComplete: Boolean, +) + +internal sealed interface OnboardingCoInsuredUiState { + data object Loading : OnboardingCoInsuredUiState + + data object Error : OnboardingCoInsuredUiState + + data class Content( + val progress: OnboardingProgress, + val rows: List, + ) : OnboardingCoInsuredUiState +} + +internal sealed interface OnboardingCoInsuredEvent { + data object Retry : OnboardingCoInsuredEvent + + data object Close : OnboardingCoInsuredEvent + + data object Refresh : OnboardingCoInsuredEvent + + data class AddCoInsured(val contractId: String, val flowType: CoInsuredFlowType) : OnboardingCoInsuredEvent + + data object Continue : OnboardingCoInsuredEvent +} diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt index 1b414e9535..effecbb5d7 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt @@ -65,90 +65,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource -@Inject -@HedvigViewModel(ActivityRetainedScope::class) -internal class OnboardingInviteViewModel( - sessionStore: OnboardingSessionStore, - navigator: OnboardingNavigator, - val progressBarAnimation: OnboardingProgressBarAnimation, -) : MoleculeViewModel( - initialState = OnboardingInviteUiState.Loading, - presenter = OnboardingInvitePresenter(sessionStore, navigator), - ) - -internal class OnboardingInvitePresenter( - private val sessionStore: OnboardingSessionStore, - private val navigator: OnboardingNavigator, -) : MoleculePresenter { - @Composable - override fun MoleculePresenterScope.present( - lastState: OnboardingInviteUiState, - ): OnboardingInviteUiState { - var currentState by remember { mutableStateOf(lastState) } - var loadIteration by remember { mutableIntStateOf(0) } - var inviteCardAnimationPlayed by remember { mutableStateOf(false) } - - LaunchedEffect(loadIteration) { - if (currentState is OnboardingInviteUiState.Content) return@LaunchedEffect - currentState = OnboardingInviteUiState.Loading - sessionStore.getOrFetchSession().fold( - ifLeft = { currentState = OnboardingInviteUiState.Error }, - ifRight = { session -> - val referral = session.data.referralInformation - currentState = if (referral == null) { - OnboardingInviteUiState.Error - } else { - OnboardingInviteUiState.Content( - progress = session.progressFor(OnboardingStepId.InviteFriend), - // toInt is safe: referral incentives are always whole amounts, per product. - incentiveDisplay = "${referral.monthlyDiscountPerReferralAmount.toInt()} ${referral.currencyCode}", - ) - } - }, - ) - } - - CollectEvents { event -> - when (event) { - OnboardingInviteEvent.Retry -> loadIteration++ - OnboardingInviteEvent.Close -> launch { navigator.exitOnboarding() } - OnboardingInviteEvent.Continue -> launch { navigator.continueFrom(OnboardingStepId.InviteFriend) } - OnboardingInviteEvent.InviteFriend -> navigator.openForeverScreen() - OnboardingInviteEvent.InviteCardAnimationCompleted -> inviteCardAnimationPlayed = true - } - } - - return when (val state = currentState) { - is OnboardingInviteUiState.Content -> state.copy(inviteCardAnimationPlayed = inviteCardAnimationPlayed) - else -> state - } - } -} - -internal sealed interface OnboardingInviteUiState { - data object Loading : OnboardingInviteUiState - - data object Error : OnboardingInviteUiState - - data class Content( - val progress: OnboardingProgress, - val incentiveDisplay: String, - val inviteCardAnimationPlayed: Boolean = false, - ) : OnboardingInviteUiState -} - -internal sealed interface OnboardingInviteEvent { - data object Retry : OnboardingInviteEvent - - data object Close : OnboardingInviteEvent - - data object Continue : OnboardingInviteEvent - - data object InviteFriend : OnboardingInviteEvent - - data object InviteCardAnimationCompleted : OnboardingInviteEvent -} - @Composable internal fun OnboardingInviteDestination(viewModel: OnboardingInviteViewModel, navigateUp: () -> Unit) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteViewModel.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteViewModel.kt new file mode 100644 index 0000000000..9298a61cfb --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteViewModel.kt @@ -0,0 +1,150 @@ +package com.hedvig.android.feature.onboarding.ui.invite + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.TweenSpec +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.hedvig.android.core.common.di.ActivityRetainedScope +import com.hedvig.android.core.common.di.HedvigViewModel +import com.hedvig.android.design.system.hedvig.DividerPosition +import com.hedvig.android.design.system.hedvig.HedvigErrorSection +import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgressDebounced +import com.hedvig.android.design.system.hedvig.HedvigPreview +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.Surface +import com.hedvig.android.design.system.hedvig.hedvigDropShadow +import com.hedvig.android.design.system.hedvig.horizontalDivider +import com.hedvig.android.design.system.hedvig.tokens.MotionTokens +import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore +import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator +import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId +import com.hedvig.android.feature.onboarding.ui.OnboardingProgress +import com.hedvig.android.feature.onboarding.ui.OnboardingProgressBarAnimation +import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons +import com.hedvig.android.feature.onboarding.ui.OnboardingStepHeader +import com.hedvig.android.feature.onboarding.ui.OnboardingStepScaffold +import com.hedvig.android.feature.onboarding.ui.progressFor +import com.hedvig.android.molecule.public.MoleculePresenter +import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.molecule.public.MoleculeViewModel +import dev.zacsweers.metro.Inject +import hedvig.resources.ONBOARDING_INVITE_FRIEND_SUBTITLE +import hedvig.resources.ONBOARDING_INVITE_FRIEND_TITLE +import hedvig.resources.Res +import hedvig.resources.general_continue_button +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource + +@Inject +@HedvigViewModel(ActivityRetainedScope::class) +internal class OnboardingInviteViewModel( + sessionStore: OnboardingSessionStore, + navigator: OnboardingNavigator, + val progressBarAnimation: OnboardingProgressBarAnimation, +) : MoleculeViewModel( + initialState = OnboardingInviteUiState.Loading, + presenter = OnboardingInvitePresenter(sessionStore, navigator), + ) + +internal class OnboardingInvitePresenter( + private val sessionStore: OnboardingSessionStore, + private val navigator: OnboardingNavigator, +) : MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present( + lastState: OnboardingInviteUiState, + ): OnboardingInviteUiState { + var currentState by remember { mutableStateOf(lastState) } + var loadIteration by remember { mutableIntStateOf(0) } + var inviteCardAnimationPlayed by remember { mutableStateOf(false) } + + LaunchedEffect(loadIteration) { + if (currentState is OnboardingInviteUiState.Content) return@LaunchedEffect + currentState = OnboardingInviteUiState.Loading + sessionStore.getOrFetchSession().fold( + ifLeft = { currentState = OnboardingInviteUiState.Error }, + ifRight = { session -> + val referral = session.data.referralInformation + currentState = if (referral == null) { + OnboardingInviteUiState.Error + } else { + OnboardingInviteUiState.Content( + progress = session.progressFor(OnboardingStepId.InviteFriend), + // toInt is safe: referral incentives are always whole amounts, per product. + incentiveDisplay = "${referral.monthlyDiscountPerReferralAmount.toInt()} ${referral.currencyCode}", + ) + } + }, + ) + } + + CollectEvents { event -> + when (event) { + OnboardingInviteEvent.Retry -> loadIteration++ + OnboardingInviteEvent.Close -> launch { navigator.exitOnboarding() } + OnboardingInviteEvent.Continue -> launch { navigator.continueFrom(OnboardingStepId.InviteFriend) } + OnboardingInviteEvent.InviteFriend -> navigator.openForeverScreen() + OnboardingInviteEvent.InviteCardAnimationCompleted -> inviteCardAnimationPlayed = true + } + } + + return when (val state = currentState) { + is OnboardingInviteUiState.Content -> state.copy(inviteCardAnimationPlayed = inviteCardAnimationPlayed) + else -> state + } + } +} + +internal sealed interface OnboardingInviteUiState { + data object Loading : OnboardingInviteUiState + + data object Error : OnboardingInviteUiState + + data class Content( + val progress: OnboardingProgress, + val incentiveDisplay: String, + val inviteCardAnimationPlayed: Boolean = false, + ) : OnboardingInviteUiState +} + +internal sealed interface OnboardingInviteEvent { + data object Retry : OnboardingInviteEvent + + data object Close : OnboardingInviteEvent + + data object Continue : OnboardingInviteEvent + + data object InviteFriend : OnboardingInviteEvent + + data object InviteCardAnimationCompleted : OnboardingInviteEvent +} diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdDestination.kt index 4b8013c69e..b51011e1bb 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdDestination.kt @@ -53,107 +53,6 @@ import hedvig.resources.general_continue_button import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource -@Inject -@HedvigViewModel(ActivityRetainedScope::class) -internal class OnboardingPetIdViewModel( - sessionStore: OnboardingSessionStore, - navigator: OnboardingNavigator, - val progressBarAnimation: OnboardingProgressBarAnimation, -) : MoleculeViewModel( - initialState = OnboardingPetIdUiState.Loading, - presenter = OnboardingPetIdPresenter(sessionStore, navigator), - ) - -internal class OnboardingPetIdPresenter( - private val sessionStore: OnboardingSessionStore, - private val navigator: OnboardingNavigator, -) : MoleculePresenter { - @Composable - override fun MoleculePresenterScope.present( - lastState: OnboardingPetIdUiState, - ): OnboardingPetIdUiState { - var currentState by remember { mutableStateOf(lastState) } - var loadIteration by remember { mutableIntStateOf(0) } - - // Pin which contracts belong to this step the first time data is available, so completed - // rows render as done instead of disappearing. - var pinnedContractIds by remember { mutableStateOf?>(null) } - - fun contentFrom(session: OnboardingSession): OnboardingPetIdUiState.Content { - val ids = pinnedContractIds - ?: session.data.contractsWithMissingPetId.map { it.id }.also { pinnedContractIds = it } - return OnboardingPetIdUiState.Content( - progress = session.progressFor(OnboardingStepId.PetIds), - rows = ids.mapNotNull { id -> - val contract = session.data.contracts.firstOrNull { it.id == id } ?: return@mapNotNull null - PetIdRow( - contractId = contract.id, - displayName = contract.displayName, - exposureName = contract.exposureName, - typeOfContract = contract.typeOfContract, - isComplete = !contract.isMissingPetId, - ) - }, - ) - } - - LaunchedEffect(loadIteration) { - if (currentState is OnboardingPetIdUiState.Content) return@LaunchedEffect - currentState = OnboardingPetIdUiState.Loading - sessionStore.getOrFetchSession().fold( - ifLeft = { currentState = OnboardingPetIdUiState.Error }, - ifRight = { session -> currentState = contentFrom(session) }, - ) - } - - CollectEvents { event -> - when (event) { - OnboardingPetIdEvent.Retry -> loadIteration++ - - OnboardingPetIdEvent.Close -> launch { navigator.exitOnboarding() } - - OnboardingPetIdEvent.Refresh -> launch { - sessionStore.refreshData().onRight { refreshed -> currentState = contentFrom(refreshed) } - // onLeft: keep the previously shown state, per the spec's error handling. - } - - OnboardingPetIdEvent.Continue -> launch { navigator.continueFrom(OnboardingStepId.PetIds) } - } - } - - return currentState - } -} - -internal data class PetIdRow( - val contractId: String, - val displayName: String, - val exposureName: String, - val typeOfContract: String, - val isComplete: Boolean, -) - -internal sealed interface OnboardingPetIdUiState { - data object Loading : OnboardingPetIdUiState - - data object Error : OnboardingPetIdUiState - - data class Content( - val progress: OnboardingProgress, - val rows: List, - ) : OnboardingPetIdUiState -} - -internal sealed interface OnboardingPetIdEvent { - data object Retry : OnboardingPetIdEvent - - data object Close : OnboardingPetIdEvent - - data object Refresh : OnboardingPetIdEvent - - data object Continue : OnboardingPetIdEvent -} - @Composable internal fun OnboardingPetIdDestination( viewModel: OnboardingPetIdViewModel, diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdViewModel.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdViewModel.kt new file mode 100644 index 0000000000..ea34212248 --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdViewModel.kt @@ -0,0 +1,155 @@ +package com.hedvig.android.feature.onboarding.ui.petid + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.hedvig.android.core.common.di.ActivityRetainedScope +import com.hedvig.android.core.common.di.HedvigViewModel +import com.hedvig.android.design.system.hedvig.HedvigErrorSection +import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgressDebounced +import com.hedvig.android.design.system.hedvig.HedvigPreview +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.Surface +import com.hedvig.android.feature.onboarding.data.OnboardingSession +import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore +import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator +import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId +import com.hedvig.android.feature.onboarding.ui.OnboardingContractCard +import com.hedvig.android.feature.onboarding.ui.OnboardingProgress +import com.hedvig.android.feature.onboarding.ui.OnboardingProgressBarAnimation +import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons +import com.hedvig.android.feature.onboarding.ui.OnboardingStepHeader +import com.hedvig.android.feature.onboarding.ui.OnboardingStepScaffold +import com.hedvig.android.feature.onboarding.ui.progressFor +import com.hedvig.android.molecule.public.MoleculePresenter +import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.molecule.public.MoleculeViewModel +import dev.zacsweers.metro.Inject +import hedvig.resources.ONBOARDING_ADD_INFO_LATER_LABEL +import hedvig.resources.ONBOARDING_ADD_PET_CHIP_IDS_TITLE +import hedvig.resources.ONBOARDING_DO_THIS_LATER_BUTTON +import hedvig.resources.ONBOARDING_MISSING_INFO_PET_SUBTITLE +import hedvig.resources.Res +import hedvig.resources.general_continue_button +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource + +@Inject +@HedvigViewModel(ActivityRetainedScope::class) +internal class OnboardingPetIdViewModel( + sessionStore: OnboardingSessionStore, + navigator: OnboardingNavigator, + val progressBarAnimation: OnboardingProgressBarAnimation, +) : MoleculeViewModel( + initialState = OnboardingPetIdUiState.Loading, + presenter = OnboardingPetIdPresenter(sessionStore, navigator), + ) + +internal class OnboardingPetIdPresenter( + private val sessionStore: OnboardingSessionStore, + private val navigator: OnboardingNavigator, +) : MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present( + lastState: OnboardingPetIdUiState, + ): OnboardingPetIdUiState { + var currentState by remember { mutableStateOf(lastState) } + var loadIteration by remember { mutableIntStateOf(0) } + + // Pin which contracts belong to this step the first time data is available, so completed + // rows render as done instead of disappearing. + var pinnedContractIds by remember { mutableStateOf?>(null) } + + fun contentFrom(session: OnboardingSession): OnboardingPetIdUiState.Content { + val ids = pinnedContractIds + ?: session.data.contractsWithMissingPetId.map { it.id }.also { pinnedContractIds = it } + return OnboardingPetIdUiState.Content( + progress = session.progressFor(OnboardingStepId.PetIds), + rows = ids.mapNotNull { id -> + val contract = session.data.contracts.firstOrNull { it.id == id } ?: return@mapNotNull null + PetIdRow( + contractId = contract.id, + displayName = contract.displayName, + exposureName = contract.exposureName, + typeOfContract = contract.typeOfContract, + isComplete = !contract.isMissingPetId, + ) + }, + ) + } + + LaunchedEffect(loadIteration) { + if (currentState is OnboardingPetIdUiState.Content) return@LaunchedEffect + currentState = OnboardingPetIdUiState.Loading + sessionStore.getOrFetchSession().fold( + ifLeft = { currentState = OnboardingPetIdUiState.Error }, + ifRight = { session -> currentState = contentFrom(session) }, + ) + } + + CollectEvents { event -> + when (event) { + OnboardingPetIdEvent.Retry -> loadIteration++ + + OnboardingPetIdEvent.Close -> launch { navigator.exitOnboarding() } + + OnboardingPetIdEvent.Refresh -> launch { + sessionStore.refreshData().onRight { refreshed -> currentState = contentFrom(refreshed) } + // onLeft: keep the previously shown state, per the spec's error handling. + } + + OnboardingPetIdEvent.Continue -> launch { navigator.continueFrom(OnboardingStepId.PetIds) } + } + } + + return currentState + } +} + +internal data class PetIdRow( + val contractId: String, + val displayName: String, + val exposureName: String, + val typeOfContract: String, + val isComplete: Boolean, +) + +internal sealed interface OnboardingPetIdUiState { + data object Loading : OnboardingPetIdUiState + + data object Error : OnboardingPetIdUiState + + data class Content( + val progress: OnboardingProgress, + val rows: List, + ) : OnboardingPetIdUiState +} + +internal sealed interface OnboardingPetIdEvent { + data object Retry : OnboardingPetIdEvent + + data object Close : OnboardingPetIdEvent + + data object Refresh : OnboardingPetIdEvent + + data object Continue : OnboardingPetIdEvent +} diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt index c118dc11f7..50239933cb 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt @@ -49,122 +49,6 @@ import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource -@Inject -@HedvigViewModel(ActivityRetainedScope::class) -internal class OnboardingPhoneViewModel( - sessionStore: OnboardingSessionStore, - navigator: OnboardingNavigator, - onboardingRepository: OnboardingRepository, - val progressBarAnimation: OnboardingProgressBarAnimation, -) : MoleculeViewModel( - initialState = OnboardingPhoneUiState.Loading, - presenter = OnboardingPhonePresenter(sessionStore, navigator, onboardingRepository), - ) - -internal class OnboardingPhonePresenter( - private val sessionStore: OnboardingSessionStore, - private val navigator: OnboardingNavigator, - private val onboardingRepository: OnboardingRepository, -) : MoleculePresenter { - @Composable - override fun MoleculePresenterScope.present( - lastState: OnboardingPhoneUiState, - ): OnboardingPhoneUiState { - var currentState by remember { mutableStateOf(lastState) } - var loadIteration by remember { mutableIntStateOf(0) } - var submitIteration by remember { mutableIntStateOf(0) } - var phoneNumberToSubmit by remember { mutableStateOf("") } - - LaunchedEffect(loadIteration) { - if (currentState is OnboardingPhoneUiState.Content) return@LaunchedEffect - currentState = OnboardingPhoneUiState.Loading - sessionStore.getOrFetchSession().fold( - ifLeft = { currentState = OnboardingPhoneUiState.Error }, - ifRight = { session -> - currentState = OnboardingPhoneUiState.Content( - progress = session.progressFor(OnboardingStepId.PhoneNumber), - phoneNumber = session.data.phoneNumber.orEmpty(), - ) - }, - ) - } - - LaunchedEffect(submitIteration) { - if (submitIteration == 0) return@LaunchedEffect - val content = currentState as? OnboardingPhoneUiState.Content ?: return@LaunchedEffect - val session = sessionStore.currentSession ?: return@LaunchedEffect - currentState = content.copy(isSubmitting = true, showSubmissionError = false) - onboardingRepository.updateContactInfo( - email = session.data.email, - phoneNumber = phoneNumberToSubmit, - ).fold( - ifLeft = { - currentState = (currentState as? OnboardingPhoneUiState.Content ?: content) - .copy(isSubmitting = false, showSubmissionError = true) - }, - ifRight = { - // Reset before navigating so returning to this retained entry does not leave Save disabled. - currentState = content.copy(isSubmitting = false) - navigator.continueFrom(OnboardingStepId.PhoneNumber) - }, - ) - } - - CollectEvents { event -> - when (event) { - OnboardingPhoneEvent.Retry -> { - loadIteration++ - } - - OnboardingPhoneEvent.Close -> { - launch { navigator.exitOnboarding() } - } - - is OnboardingPhoneEvent.Save -> { - phoneNumberToSubmit = event.phoneNumber - submitIteration++ - } - - OnboardingPhoneEvent.DoThisLater -> { - launch { navigator.continueFrom(OnboardingStepId.PhoneNumber) } - } - - OnboardingPhoneEvent.ClearSubmissionError -> { - val content = currentState as? OnboardingPhoneUiState.Content ?: return@CollectEvents - currentState = content.copy(showSubmissionError = false) - } - } - } - - return currentState - } -} - -internal sealed interface OnboardingPhoneUiState { - data object Loading : OnboardingPhoneUiState - - data object Error : OnboardingPhoneUiState - - data class Content( - val progress: OnboardingProgress, - val phoneNumber: String, - val isSubmitting: Boolean = false, - val showSubmissionError: Boolean = false, - ) : OnboardingPhoneUiState -} - -internal sealed interface OnboardingPhoneEvent { - data object Retry : OnboardingPhoneEvent - - data object Close : OnboardingPhoneEvent - - data class Save(val phoneNumber: String) : OnboardingPhoneEvent - - data object DoThisLater : OnboardingPhoneEvent - - data object ClearSubmissionError : OnboardingPhoneEvent -} - @Composable internal fun OnboardingPhoneDestination(viewModel: OnboardingPhoneViewModel, navigateUp: () -> Unit) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneViewModel.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneViewModel.kt new file mode 100644 index 0000000000..852a0bb29b --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneViewModel.kt @@ -0,0 +1,166 @@ +package com.hedvig.android.feature.onboarding.ui.phone + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.hedvig.android.core.common.di.ActivityRetainedScope +import com.hedvig.android.core.common.di.HedvigViewModel +import com.hedvig.android.design.system.hedvig.HedvigErrorSection +import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgressDebounced +import com.hedvig.android.design.system.hedvig.HedvigTextField +import com.hedvig.android.design.system.hedvig.HedvigTextFieldDefaults +import com.hedvig.android.feature.onboarding.data.OnboardingRepository +import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore +import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator +import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId +import com.hedvig.android.feature.onboarding.ui.OnboardingProgress +import com.hedvig.android.feature.onboarding.ui.OnboardingProgressBarAnimation +import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons +import com.hedvig.android.feature.onboarding.ui.OnboardingStepHeader +import com.hedvig.android.feature.onboarding.ui.OnboardingStepScaffold +import com.hedvig.android.feature.onboarding.ui.progressFor +import com.hedvig.android.molecule.public.MoleculePresenter +import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.molecule.public.MoleculeViewModel +import dev.zacsweers.metro.Inject +import hedvig.resources.ONBOARDING_DO_THIS_LATER_BUTTON +import hedvig.resources.ONBOARDING_PHONE_SAVE_ERROR +import hedvig.resources.ONBOARDING_PHONE_SUBTITLE +import hedvig.resources.ONBOARDING_PHONE_TITLE +import hedvig.resources.Res +import hedvig.resources.general_save_button +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource + +@Inject +@HedvigViewModel(ActivityRetainedScope::class) +internal class OnboardingPhoneViewModel( + sessionStore: OnboardingSessionStore, + navigator: OnboardingNavigator, + onboardingRepository: OnboardingRepository, + val progressBarAnimation: OnboardingProgressBarAnimation, +) : MoleculeViewModel( + initialState = OnboardingPhoneUiState.Loading, + presenter = OnboardingPhonePresenter(sessionStore, navigator, onboardingRepository), + ) + +internal class OnboardingPhonePresenter( + private val sessionStore: OnboardingSessionStore, + private val navigator: OnboardingNavigator, + private val onboardingRepository: OnboardingRepository, +) : MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present( + lastState: OnboardingPhoneUiState, + ): OnboardingPhoneUiState { + var currentState by remember { mutableStateOf(lastState) } + var loadIteration by remember { mutableIntStateOf(0) } + var submitIteration by remember { mutableIntStateOf(0) } + var phoneNumberToSubmit by remember { mutableStateOf("") } + + LaunchedEffect(loadIteration) { + if (currentState is OnboardingPhoneUiState.Content) return@LaunchedEffect + currentState = OnboardingPhoneUiState.Loading + sessionStore.getOrFetchSession().fold( + ifLeft = { currentState = OnboardingPhoneUiState.Error }, + ifRight = { session -> + currentState = OnboardingPhoneUiState.Content( + progress = session.progressFor(OnboardingStepId.PhoneNumber), + phoneNumber = session.data.phoneNumber.orEmpty(), + ) + }, + ) + } + + LaunchedEffect(submitIteration) { + if (submitIteration == 0) return@LaunchedEffect + val content = currentState as? OnboardingPhoneUiState.Content ?: return@LaunchedEffect + val session = sessionStore.currentSession ?: return@LaunchedEffect + currentState = content.copy(isSubmitting = true, showSubmissionError = false) + onboardingRepository.updateContactInfo( + email = session.data.email, + phoneNumber = phoneNumberToSubmit, + ).fold( + ifLeft = { + currentState = (currentState as? OnboardingPhoneUiState.Content ?: content) + .copy(isSubmitting = false, showSubmissionError = true) + }, + ifRight = { + // Reset before navigating so returning to this retained entry does not leave Save disabled. + currentState = content.copy(isSubmitting = false) + navigator.continueFrom(OnboardingStepId.PhoneNumber) + }, + ) + } + + CollectEvents { event -> + when (event) { + OnboardingPhoneEvent.Retry -> { + loadIteration++ + } + + OnboardingPhoneEvent.Close -> { + launch { navigator.exitOnboarding() } + } + + is OnboardingPhoneEvent.Save -> { + phoneNumberToSubmit = event.phoneNumber + submitIteration++ + } + + OnboardingPhoneEvent.DoThisLater -> { + launch { navigator.continueFrom(OnboardingStepId.PhoneNumber) } + } + + OnboardingPhoneEvent.ClearSubmissionError -> { + val content = currentState as? OnboardingPhoneUiState.Content ?: return@CollectEvents + currentState = content.copy(showSubmissionError = false) + } + } + } + + return currentState + } +} + +internal sealed interface OnboardingPhoneUiState { + data object Loading : OnboardingPhoneUiState + + data object Error : OnboardingPhoneUiState + + data class Content( + val progress: OnboardingProgress, + val phoneNumber: String, + val isSubmitting: Boolean = false, + val showSubmissionError: Boolean = false, + ) : OnboardingPhoneUiState +} + +internal sealed interface OnboardingPhoneEvent { + data object Retry : OnboardingPhoneEvent + + data object Close : OnboardingPhoneEvent + + data class Save(val phoneNumber: String) : OnboardingPhoneEvent + + data object DoThisLater : OnboardingPhoneEvent + + data object ClearSubmissionError : OnboardingPhoneEvent +} diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt index e7d54d78a8..89bd9acc5e 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt @@ -70,87 +70,6 @@ import hedvig.resources.general_continue_button import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource -@Inject -@HedvigViewModel(ActivityRetainedScope::class) -internal class OnboardingThemeViewModel( - sessionStore: OnboardingSessionStore, - navigator: OnboardingNavigator, - settingsDataStore: SettingsDataStore, - val progressBarAnimation: OnboardingProgressBarAnimation, -) : MoleculeViewModel( - initialState = OnboardingThemeUiState.Loading, - presenter = OnboardingThemePresenter(sessionStore, navigator, settingsDataStore), - ) - -internal class OnboardingThemePresenter( - private val sessionStore: OnboardingSessionStore, - private val navigator: OnboardingNavigator, - private val settingsDataStore: SettingsDataStore, -) : MoleculePresenter { - @Composable - override fun MoleculePresenterScope.present( - lastState: OnboardingThemeUiState, - ): OnboardingThemeUiState { - var currentState by remember { mutableStateOf(lastState) } - var loadIteration by remember { mutableIntStateOf(0) } - - val storedTheme = settingsDataStore.observeTheme().collectAsState(null).value - - LaunchedEffect(loadIteration) { - if (currentState is OnboardingThemeUiState.Content) return@LaunchedEffect - currentState = OnboardingThemeUiState.Loading - sessionStore.getOrFetchSession().fold( - ifLeft = { currentState = OnboardingThemeUiState.Error }, - ifRight = { session -> - currentState = OnboardingThemeUiState.Content( - progress = session.progressFor(OnboardingStepId.Theme), - // Placeholder; the return site merges the live stored theme. - selectedTheme = Theme.SYSTEM_DEFAULT, - ) - }, - ) - } - - CollectEvents { event -> - when (event) { - OnboardingThemeEvent.Retry -> loadIteration++ - OnboardingThemeEvent.Close -> launch { navigator.exitOnboarding() } - is OnboardingThemeEvent.SelectTheme -> launch { settingsDataStore.setTheme(event.theme) } - OnboardingThemeEvent.Continue -> launch { navigator.continueFrom(OnboardingStepId.Theme) } - } - } - - return if (currentState is OnboardingThemeUiState.Content) { - (currentState as OnboardingThemeUiState.Content).copy( - selectedTheme = storedTheme ?: Theme.SYSTEM_DEFAULT, - ) - } else { - currentState - } - } -} - -internal sealed interface OnboardingThemeUiState { - data object Loading : OnboardingThemeUiState - - data object Error : OnboardingThemeUiState - - data class Content( - val progress: OnboardingProgress, - val selectedTheme: Theme, - ) : OnboardingThemeUiState -} - -internal sealed interface OnboardingThemeEvent { - data object Retry : OnboardingThemeEvent - - data object Close : OnboardingThemeEvent - - data class SelectTheme(val theme: Theme) : OnboardingThemeEvent - - data object Continue : OnboardingThemeEvent -} - @Composable internal fun OnboardingThemeDestination(viewModel: OnboardingThemeViewModel, navigateUp: () -> Unit) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeViewModel.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeViewModel.kt new file mode 100644 index 0000000000..6773fef0ce --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeViewModel.kt @@ -0,0 +1,152 @@ +package com.hedvig.android.feature.onboarding.ui.theme + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.selection.selectable +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.hedvig.android.core.common.di.ActivityRetainedScope +import com.hedvig.android.core.common.di.HedvigViewModel +import com.hedvig.android.data.settings.datastore.SettingsDataStore +import com.hedvig.android.design.system.hedvig.HedvigErrorSection +import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgressDebounced +import com.hedvig.android.design.system.hedvig.HedvigPreview +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.Surface +import com.hedvig.android.design.system.hedvig.icon.HedvigIcons +import com.hedvig.android.design.system.hedvig.icon.Settings +import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore +import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator +import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId +import com.hedvig.android.feature.onboarding.ui.OnboardingProgress +import com.hedvig.android.feature.onboarding.ui.OnboardingProgressBarAnimation +import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons +import com.hedvig.android.feature.onboarding.ui.OnboardingStepHeader +import com.hedvig.android.feature.onboarding.ui.OnboardingStepScaffold +import com.hedvig.android.feature.onboarding.ui.progressFor +import com.hedvig.android.molecule.public.MoleculePresenter +import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.molecule.public.MoleculeViewModel +import com.hedvig.android.theme.Theme +import dev.zacsweers.metro.Inject +import hedvig.resources.ONBOARDING_CHANGE_SETTINGS_LATER_LABEL +import hedvig.resources.ONBOARDING_THEME_DARK_SUBTITLE +import hedvig.resources.ONBOARDING_THEME_LIGHT_SUBTITLE +import hedvig.resources.ONBOARDING_THEME_SUBTITLE +import hedvig.resources.ONBOARDING_THEME_SYSTEM_SUBTITLE +import hedvig.resources.Res +import hedvig.resources.SETTINGS_THEME_DARK +import hedvig.resources.SETTINGS_THEME_DIALOG_TITLE +import hedvig.resources.SETTINGS_THEME_LIGHT +import hedvig.resources.SETTINGS_THEME_SYSTEM_DEFAULT +import hedvig.resources.general_continue_button +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource + +@Inject +@HedvigViewModel(ActivityRetainedScope::class) +internal class OnboardingThemeViewModel( + sessionStore: OnboardingSessionStore, + navigator: OnboardingNavigator, + settingsDataStore: SettingsDataStore, + val progressBarAnimation: OnboardingProgressBarAnimation, +) : MoleculeViewModel( + initialState = OnboardingThemeUiState.Loading, + presenter = OnboardingThemePresenter(sessionStore, navigator, settingsDataStore), + ) + +internal class OnboardingThemePresenter( + private val sessionStore: OnboardingSessionStore, + private val navigator: OnboardingNavigator, + private val settingsDataStore: SettingsDataStore, +) : MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present( + lastState: OnboardingThemeUiState, + ): OnboardingThemeUiState { + var currentState by remember { mutableStateOf(lastState) } + var loadIteration by remember { mutableIntStateOf(0) } + + val storedTheme = settingsDataStore.observeTheme().collectAsState(null).value + + LaunchedEffect(loadIteration) { + if (currentState is OnboardingThemeUiState.Content) return@LaunchedEffect + currentState = OnboardingThemeUiState.Loading + sessionStore.getOrFetchSession().fold( + ifLeft = { currentState = OnboardingThemeUiState.Error }, + ifRight = { session -> + currentState = OnboardingThemeUiState.Content( + progress = session.progressFor(OnboardingStepId.Theme), + // Placeholder; the return site merges the live stored theme. + selectedTheme = Theme.SYSTEM_DEFAULT, + ) + }, + ) + } + + CollectEvents { event -> + when (event) { + OnboardingThemeEvent.Retry -> loadIteration++ + OnboardingThemeEvent.Close -> launch { navigator.exitOnboarding() } + is OnboardingThemeEvent.SelectTheme -> launch { settingsDataStore.setTheme(event.theme) } + OnboardingThemeEvent.Continue -> launch { navigator.continueFrom(OnboardingStepId.Theme) } + } + } + + return if (currentState is OnboardingThemeUiState.Content) { + (currentState as OnboardingThemeUiState.Content).copy( + selectedTheme = storedTheme ?: Theme.SYSTEM_DEFAULT, + ) + } else { + currentState + } + } +} + +internal sealed interface OnboardingThemeUiState { + data object Loading : OnboardingThemeUiState + + data object Error : OnboardingThemeUiState + + data class Content( + val progress: OnboardingProgress, + val selectedTheme: Theme, + ) : OnboardingThemeUiState +} + +internal sealed interface OnboardingThemeEvent { + data object Retry : OnboardingThemeEvent + + data object Close : OnboardingThemeEvent + + data class SelectTheme(val theme: Theme) : OnboardingThemeEvent + + data object Continue : OnboardingThemeEvent +} From 68243effe26e96568b40c0c210380016ad44d831 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 4 Aug 2026 15:35:41 +0200 Subject: [PATCH 088/119] Split the phone step into a previewable screen and standardize theme naming Extract a stateless OnboardingPhoneScreen: the phone TextFieldState stays as local editor state in the screen and the text-change effect calls an onClearSubmissionError callback instead of the ViewModel, keeping it pure and previewable. Rename theme's stateless composable from an overloaded OnboardingThemeDestination to OnboardingThemeScreen so every step uses the Destination + Screen convention. --- .../ui/phone/OnboardingPhoneDestination.kt | 77 +++++++++++++++++-- .../ui/theme/OnboardingThemeDestination.kt | 8 +- 2 files changed, 75 insertions(+), 10 deletions(-) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt index 50239933cb..89378dc575 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt @@ -17,14 +17,19 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.hedvig.android.core.common.di.ActivityRetainedScope import com.hedvig.android.core.common.di.HedvigViewModel import com.hedvig.android.design.system.hedvig.HedvigErrorSection import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgressDebounced +import com.hedvig.android.design.system.hedvig.HedvigPreview import com.hedvig.android.design.system.hedvig.HedvigTextField import com.hedvig.android.design.system.hedvig.HedvigTextFieldDefaults +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.Surface import com.hedvig.android.feature.onboarding.data.OnboardingRepository import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator @@ -52,12 +57,35 @@ import org.jetbrains.compose.resources.stringResource @Composable internal fun OnboardingPhoneDestination(viewModel: OnboardingPhoneViewModel, navigateUp: () -> Unit) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + OnboardingPhoneScreen( + uiState = uiState, + progressAnimation = viewModel.progressBarAnimation, + navigateUp = navigateUp, + onClose = { viewModel.emit(OnboardingPhoneEvent.Close) }, + onRetry = { viewModel.emit(OnboardingPhoneEvent.Retry) }, + onSave = { viewModel.emit(OnboardingPhoneEvent.Save(it)) }, + onClearSubmissionError = { viewModel.emit(OnboardingPhoneEvent.ClearSubmissionError) }, + onDoThisLater = { viewModel.emit(OnboardingPhoneEvent.DoThisLater) }, + ) +} + +@Composable +private fun OnboardingPhoneScreen( + uiState: OnboardingPhoneUiState, + progressAnimation: OnboardingProgressBarAnimation, + navigateUp: () -> Unit, + onClose: () -> Unit, + onRetry: () -> Unit, + onSave: (phoneNumber: String) -> Unit, + onClearSubmissionError: () -> Unit, + onDoThisLater: () -> Unit, +) { OnboardingStepScaffold( progress = (uiState as? OnboardingPhoneUiState.Content)?.progress, showBackButton = true, onBackClick = navigateUp, - onCloseClick = { viewModel.emit(OnboardingPhoneEvent.Close) }, - progressAnimation = viewModel.progressBarAnimation, + onCloseClick = onClose, + progressAnimation = progressAnimation, ) { when (val content = uiState) { OnboardingPhoneUiState.Loading -> { @@ -66,7 +94,7 @@ internal fun OnboardingPhoneDestination(viewModel: OnboardingPhoneViewModel, nav OnboardingPhoneUiState.Error -> { HedvigErrorSection( - onButtonClick = { viewModel.emit(OnboardingPhoneEvent.Retry) }, + onButtonClick = onRetry, ) } @@ -75,7 +103,7 @@ internal fun OnboardingPhoneDestination(viewModel: OnboardingPhoneViewModel, nav LaunchedEffect(phoneNumberState) { snapshotFlow { phoneNumberState.text.toString() } .drop(1) - .collect { viewModel.emit(OnboardingPhoneEvent.ClearSubmissionError) } + .collect { onClearSubmissionError() } } Spacer(Modifier.height(16.dp)) OnboardingStepHeader( @@ -105,12 +133,49 @@ internal fun OnboardingPhoneDestination(viewModel: OnboardingPhoneViewModel, nav ) OnboardingStepButtons( primaryText = stringResource(Res.string.general_save_button), - onPrimaryClick = { viewModel.emit(OnboardingPhoneEvent.Save(phoneNumberState.text.toString())) }, + onPrimaryClick = { onSave(phoneNumberState.text.toString()) }, primaryEnabled = !content.isSubmitting && phoneNumberState.text.isNotBlank(), secondaryText = stringResource(Res.string.ONBOARDING_DO_THIS_LATER_BUTTON), - onSecondaryClick = { viewModel.emit(OnboardingPhoneEvent.DoThisLater) }, + onSecondaryClick = onDoThisLater, ) } } } } + +@HedvigPreview +@Composable +private fun PreviewOnboardingPhoneScreen( + @PreviewParameter(OnboardingPhoneUiStateProvider::class) uiState: OnboardingPhoneUiState, +) { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + OnboardingPhoneScreen( + uiState = uiState, + progressAnimation = remember { OnboardingProgressBarAnimation() }, + navigateUp = {}, + onClose = {}, + onRetry = {}, + onSave = {}, + onClearSubmissionError = {}, + onDoThisLater = {}, + ) + } + } +} + +private class OnboardingPhoneUiStateProvider : CollectionPreviewParameterProvider( + listOf( + OnboardingPhoneUiState.Loading, + OnboardingPhoneUiState.Error, + OnboardingPhoneUiState.Content( + progress = OnboardingProgress(totalSteps = 5, currentIndex = 3), + phoneNumber = "070 123 45 67", + ), + OnboardingPhoneUiState.Content( + progress = OnboardingProgress(totalSteps = 5, currentIndex = 3), + phoneNumber = "070 123 45 67", + showSubmissionError = true, + ), + ), +) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt index 89bd9acc5e..84b7e67234 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt @@ -73,7 +73,7 @@ import org.jetbrains.compose.resources.stringResource @Composable internal fun OnboardingThemeDestination(viewModel: OnboardingThemeViewModel, navigateUp: () -> Unit) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - OnboardingThemeDestination( + OnboardingThemeScreen( uiState = uiState, navigateUp = navigateUp, onCloseClick = { viewModel.emit(OnboardingThemeEvent.Close) }, @@ -85,7 +85,7 @@ internal fun OnboardingThemeDestination(viewModel: OnboardingThemeViewModel, nav } @Composable -private fun OnboardingThemeDestination( +private fun OnboardingThemeScreen( uiState: OnboardingThemeUiState, navigateUp: () -> Unit, onCloseClick: () -> Unit, @@ -259,9 +259,9 @@ private val DarkThemeSwatchColor = Color(0xFF121212) @HedvigPreview @Composable -private fun PreviewOnboardingThemeDestination() { +private fun PreviewOnboardingThemeScreen() { HedvigTheme { - OnboardingThemeDestination( + OnboardingThemeScreen( uiState = OnboardingThemeUiState.Content( progress = OnboardingProgress(totalSteps = 5, currentIndex = 2), selectedTheme = Theme.SYSTEM_DEFAULT, From 2350b06fc0bf5f266e0252341f00f375facc821d Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 5 Aug 2026 11:43:21 +0200 Subject: [PATCH 089/119] Match the onboarding progress bar height to the design (4dp) --- .../android/feature/onboarding/ui/OnboardingStepScaffold.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt index bf15e388f6..e8c9e9a0d4 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt @@ -206,7 +206,7 @@ private fun OnboardingProgressBar( Box( Modifier .weight(1f) - .height(2.dp) + .height(4.dp) .clip(CircleShape) .background(HedvigTheme.colorScheme.surfaceSecondary), ) { @@ -214,7 +214,7 @@ private fun OnboardingProgressBar( Box( Modifier .fillMaxWidth(sliceFill) - .height(2.dp) + .height(4.dp) .clip(CircleShape) .background(HedvigTheme.colorScheme.fillPrimary), ) From f87b97a1f515772e9ef2702c0c55f621f8f71c64 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 5 Aug 2026 11:43:21 +0200 Subject: [PATCH 090/119] Align the co-insured/pet-id contract row with the design Give the completed row the green rounded-square checkmark badge (no 'Added' text), render the exposure sub-line at the label style instead of bodySmall so it sits below the name, and add the shared elevation shadow via hedvigDropShadow. --- .../onboarding/ui/OnboardingContractCard.kt | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingContractCard.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingContractCard.kt index 5ecb2a7dd5..2afe51e479 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingContractCard.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingContractCard.kt @@ -1,6 +1,8 @@ package com.hedvig.android.feature.onboarding.ui import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -22,9 +24,9 @@ import com.hedvig.android.design.system.hedvig.HedvigCard import com.hedvig.android.design.system.hedvig.HedvigText import com.hedvig.android.design.system.hedvig.HedvigTheme import com.hedvig.android.design.system.hedvig.Icon +import com.hedvig.android.design.system.hedvig.hedvigDropShadow import com.hedvig.android.design.system.hedvig.icon.Checkmark import com.hedvig.android.design.system.hedvig.icon.HedvigIcons -import hedvig.resources.ONBOARDING_ADDED_LABEL import hedvig.resources.ONBOARDING_ADD_BUTTON import hedvig.resources.Res import org.jetbrains.compose.resources.painterResource @@ -44,7 +46,8 @@ internal fun OnboardingContractCard( color = HedvigTheme.colorScheme.surfacePrimary, modifier = modifier .fillMaxWidth() - .padding(horizontal = 16.dp), + .padding(horizontal = 16.dp) + .hedvigDropShadow(HedvigTheme.shapes.cornerLarge), ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -63,19 +66,26 @@ internal fun OnboardingContractCard( ) HedvigText( text = exposureName, - style = HedvigTheme.typography.bodySmall, + style = HedvigTheme.typography.label, color = HedvigTheme.colorScheme.textSecondary, ) } Spacer(Modifier.width(12.dp)) if (isComplete) { - HedvigText( - text = stringResource(Res.string.ONBOARDING_ADDED_LABEL), - style = HedvigTheme.typography.bodySmall, - color = HedvigTheme.colorScheme.textSecondary, - ) - Spacer(Modifier.width(8.dp)) - Icon(imageVector = HedvigIcons.Checkmark, contentDescription = null) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(24.dp) + .clip(HedvigTheme.shapes.cornerXSmall) + .background(HedvigTheme.colorScheme.signalGreenElement), + ) { + Icon( + imageVector = HedvigIcons.Checkmark, + contentDescription = null, + tint = HedvigTheme.colorScheme.fillWhite, + modifier = Modifier.size(16.dp), + ) + } } else { HedvigButton( text = stringResource(Res.string.ONBOARDING_ADD_BUTTON), From 6850b18b97b5044770151568166e961e3c0795e8 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 5 Aug 2026 11:43:21 +0200 Subject: [PATCH 091/119] Tighten the theme step's option-row spacing and footnote to the design Use 8dp between rows and 12dp row vertical padding (64dp rows), and render the 'change settings later' footnote at the label style. The circle selection ring is kept as designed (radio-button affordance). --- .../onboarding/ui/theme/OnboardingThemeDestination.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt index 84b7e67234..3587688f97 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt @@ -121,7 +121,7 @@ private fun OnboardingThemeScreen( Spacer(Modifier.weight(1f)) Spacer(Modifier.height(24.dp)) Column( - verticalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), @@ -152,7 +152,7 @@ private fun OnboardingThemeScreen( Spacer(Modifier.height(24.dp)) HedvigText( text = stringResource(Res.string.ONBOARDING_CHANGE_SETTINGS_LATER_LABEL), - style = HedvigTheme.typography.finePrint, + style = HedvigTheme.typography.label, color = HedvigTheme.colorScheme.textSecondary, textAlign = TextAlign.Center, modifier = Modifier @@ -187,7 +187,7 @@ private fun ThemeOptionRow( Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.padding(start = 12.dp, top = 16.dp, end = 16.dp, bottom = 16.dp), + modifier = Modifier.padding(start = 12.dp, top = 12.dp, end = 16.dp, bottom = 12.dp), ) { Box( Modifier From 741322faf2066cfcf5a38d68d28d8ed37150fb85 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 5 Aug 2026 11:43:21 +0200 Subject: [PATCH 092/119] Match the consent step details to the design Render the privacy-policy link at the label style, size its arrow icon to 18dp, and draw the verified card's shadow with hedvigDropShadow (the two-layer design shadow). --- .../onboarding/ui/consent/OnboardingConsentDestination.kt | 4 ++-- .../feature/onboarding/ui/consent/OnboardingVerifiedCard.kt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingConsentDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingConsentDestination.kt index 4e2bba8d7c..7377a51608 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingConsentDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingConsentDestination.kt @@ -208,13 +208,13 @@ private fun OnboardingConsentScreen( ) { HedvigText( text = stringResource(Res.string.LEGAL_PRIVACY_POLICY), - style = HedvigTheme.typography.bodySmall, + style = HedvigTheme.typography.label, textDecoration = TextDecoration.Underline, ) Icon( imageVector = HedvigIcons.ArrowNorthEast, contentDescription = null, - modifier = Modifier.size(16.dp), + modifier = Modifier.size(18.dp), ) } Spacer(Modifier.height(16.dp)) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingVerifiedCard.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingVerifiedCard.kt index dead603aef..f117392754 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingVerifiedCard.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingVerifiedCard.kt @@ -17,7 +17,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.semantics.clearAndSetSemantics @@ -25,6 +24,7 @@ import androidx.compose.ui.unit.dp import com.hedvig.android.design.system.hedvig.HedvigPreview import com.hedvig.android.design.system.hedvig.HedvigTheme import com.hedvig.android.design.system.hedvig.Surface +import com.hedvig.android.design.system.hedvig.hedvigDropShadow import com.hedvig.android.design.system.hedvig.tokens.MotionTokens import hedvig.resources.Res import hedvig.resources.onboarding_verified_badge @@ -75,7 +75,7 @@ private fun VerifiedCard(badgeVisible: Boolean, modifier: Modifier = Modifier) { Modifier .matchParentSize() .padding(start = 8.dp, top = 16.dp, end = 20.dp, bottom = 12.dp) - .shadow(elevation = 4.dp, shape = RoundedCornerShape(24.dp)), + .hedvigDropShadow(RoundedCornerShape(24.dp)), ) Image( painter = painterResource(Res.drawable.onboarding_verified_card), From 9f579b3e361c133d08d911d55435154fb1bf8123 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 5 Aug 2026 11:43:21 +0200 Subject: [PATCH 093/119] Match the bundle cross-sell row to the design Use a 48dp pillow image and render the 'bundle and save' sub-line at the label style. --- .../onboarding/ui/bundle/OnboardingBundleDestination.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt index 82bc6da60d..b227acc4db 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt @@ -237,17 +237,17 @@ private fun OnboardingCrossSellRow( error = placeholder, fallback = placeholder, imageLoader = imageLoader, - modifier = Modifier.size(40.dp), + modifier = Modifier.size(48.dp), ) } else { - Spacer(Modifier.size(40.dp)) + Spacer(Modifier.size(48.dp)) } Spacer(Modifier.width(12.dp)) Column(Modifier.weight(1f)) { HedvigText(crossSell.title, style = HedvigTheme.typography.bodySmall) HedvigText( crossSell.description, - style = HedvigTheme.typography.bodySmall, + style = HedvigTheme.typography.label, color = HedvigTheme.colorScheme.textSecondary, ) } From fb33a2f44636f6c44d9f761a8e26acc3a5660b9f Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 5 Aug 2026 11:43:21 +0200 Subject: [PATCH 094/119] Render the payment footnote and switch-accounts hint at the label style --- .../onboarding/ui/payment/OnboardingPaymentDestination.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt index 23b0ea068f..bbdb13bfc2 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt @@ -198,7 +198,7 @@ private fun OnboardingPaymentScreen( Spacer(Modifier.height(24.dp)) HedvigText( text = stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_FOOTNOTE), - style = HedvigTheme.typography.finePrint, + style = HedvigTheme.typography.label, color = HedvigTheme.colorScheme.textSecondary, modifier = Modifier .align(Alignment.CenterHorizontally) @@ -219,7 +219,7 @@ private fun OnboardingPaymentScreen( Spacer(Modifier.height(24.dp)) HedvigText( text = stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_SWITCH_ACCOUNTS_LATER), - style = HedvigTheme.typography.finePrint, + style = HedvigTheme.typography.label, color = HedvigTheme.colorScheme.textSecondary, modifier = Modifier.align(Alignment.CenterHorizontally), ) From 9854518bbd3074ab77454bcb9358bc42126d4a19 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 5 Aug 2026 13:45:21 +0200 Subject: [PATCH 095/119] Add the connected-payment graphic to the connect-payment step Replace the connected-state title with the Figma connecting graphic: a 'Bank' card linked by looping pulse dots to the Hedvig symbol, with a green checkmark that pops onto the symbol the first time the connected (pending/active) state is seen. The one-shot is tracked in MVI state via a ConnectedAnimationCompleted event. Reuses the existing pillow_hedvig drawable; no new assets. --- .../OnboardingConnectingPaymentSymbol.kt | 182 ++++++++++++++++++ .../payment/OnboardingPaymentDestination.kt | 29 ++- 2 files changed, 206 insertions(+), 5 deletions(-) create mode 100644 app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingConnectingPaymentSymbol.kt diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingConnectingPaymentSymbol.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingConnectingPaymentSymbol.kt new file mode 100644 index 0000000000..7f25452dd2 --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingConnectingPaymentSymbol.kt @@ -0,0 +1,182 @@ +package com.hedvig.android.feature.onboarding.ui.payment + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.dp +import com.hedvig.android.design.system.hedvig.HedvigPreview +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.Icon +import com.hedvig.android.design.system.hedvig.Surface +import com.hedvig.android.design.system.hedvig.hedvigDropShadow +import com.hedvig.android.design.system.hedvig.icon.Checkmark +import com.hedvig.android.design.system.hedvig.icon.HedvigIcons +import com.hedvig.android.design.system.hedvig.tokens.MotionTokens +import hedvig.resources.Res +import hedvig.resources.pillow_hedvig +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.delay +import org.jetbrains.compose.resources.painterResource + +private val SymbolSize = 74.dp +private val CheckBadgeSize = 24.dp +private val DotSize = 6.dp + +// Timing for the connected-state graphic, grouped for easy tuning with design. +private const val CheckPopDelayMillis = 400L +private const val DotPulseDurationMillis = 600 +private const val DotPulseStaggerMillis = 200 + +/** + * The connected-state graphic on the connect-payment step: a "Bank" card linked by pulsing dots to + * the Hedvig symbol, with a green checkmark that pops onto the symbol once the payment method is + * connected. The pop plays only the first time the connected state is seen (see + * [OnboardingPaymentScreen]); the dots pulse continuously. + */ +@Composable +internal fun OnboardingConnectingPaymentSymbol( + animationAlreadyPlayed: Boolean, + onAnimationCompleted: () -> Unit, + modifier: Modifier = Modifier, +) { + var checkVisible by remember { mutableStateOf(animationAlreadyPlayed) } + LaunchedEffect(Unit) { + if (animationAlreadyPlayed) return@LaunchedEffect + delay(CheckPopDelayMillis.milliseconds) + checkVisible = true + onAnimationCompleted() + } + ConnectingGraphic(checkVisible = checkVisible, modifier = modifier) +} + +@Composable +private fun ConnectingGraphic(checkVisible: Boolean, modifier: Modifier = Modifier) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + BankCard() + LoaderDots() + HedvigSymbolWithCheck(checkVisible = checkVisible) + } +} + +@Composable +private fun BankCard() { + Surface( + shape = HedvigTheme.shapes.cornerXLarge, + color = HedvigTheme.colorScheme.backgroundPrimary, + border = HedvigTheme.colorScheme.borderSecondary, + modifier = Modifier + .size(SymbolSize) + .hedvigDropShadow(HedvigTheme.shapes.cornerXLarge), + ) { + Box(contentAlignment = Alignment.Center) { + // TODO: Add "Bank" / "Bank" to Lokalise + HedvigText(text = "Bank", style = HedvigTheme.typography.bodySmall) + } + } +} + +@Composable +private fun LoaderDots() { + val transition = rememberInfiniteTransition(label = "connecting loader dots") + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + repeat(3) { index -> + val alpha by transition.animateFloat( + initialValue = 0.3f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(DotPulseDurationMillis, delayMillis = index * DotPulseStaggerMillis, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "connecting dot $index", + ) + Box( + Modifier + .size(DotSize) + .graphicsLayer { this.alpha = alpha } + .clip(CircleShape) + .background(HedvigTheme.colorScheme.fillPrimary), + ) + } + } +} + +@Composable +private fun HedvigSymbolWithCheck(checkVisible: Boolean) { + val checkScale by animateFloatAsState( + targetValue = if (checkVisible) 1f else 0f, + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMediumLow), + label = "connected check scale", + ) + val checkAlpha by animateFloatAsState( + targetValue = if (checkVisible) 1f else 0f, + animationSpec = tween(MotionTokens.DurationShort4.toInt(), easing = MotionTokens.EasingStandardCubicBezier), + label = "connected check alpha", + ) + Box { + Image( + painter = painterResource(Res.drawable.pillow_hedvig), + contentDescription = null, + modifier = Modifier.size(SymbolSize), + ) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .align(Alignment.TopEnd) + .offset(x = 6.dp, y = (-6).dp) + .size(CheckBadgeSize) + .graphicsLayer { + scaleX = checkScale + scaleY = checkScale + alpha = checkAlpha + } + .clip(CircleShape) + .background(HedvigTheme.colorScheme.signalGreenElement), + ) { + Icon( + imageVector = HedvigIcons.Checkmark, + contentDescription = null, + tint = HedvigTheme.colorScheme.fillWhite, + modifier = Modifier.size(16.dp), + ) + } + } +} + +@HedvigPreview +@Composable +private fun PreviewOnboardingConnectingPaymentSymbol() { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + ConnectingGraphic(checkVisible = true, modifier = Modifier.size(width = 210.dp, height = 74.dp)) + } + } +} diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt index bbdb13bfc2..ea3b5b8d9e 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt @@ -70,6 +70,7 @@ internal class OnboardingPaymentPresenter( ): OnboardingPaymentUiState { var currentState by remember { mutableStateOf(lastState) } var loadIteration by remember { mutableIntStateOf(0) } + var connectedAnimationPlayed by remember { mutableStateOf(false) } LaunchedEffect(loadIteration) { if (currentState is OnboardingPaymentUiState.Content) return@LaunchedEffect @@ -103,10 +104,15 @@ internal class OnboardingPaymentPresenter( OnboardingPaymentEvent.ConnectPayment -> navigator.openConnectPayment() OnboardingPaymentEvent.Continue -> launch { navigator.continueFrom(OnboardingStepId.ConnectPayment) } + + OnboardingPaymentEvent.ConnectedAnimationCompleted -> connectedAnimationPlayed = true } } - return currentState + return when (val state = currentState) { + is OnboardingPaymentUiState.Content -> state.copy(connectedAnimationPlayed = connectedAnimationPlayed) + else -> state + } } } @@ -118,6 +124,7 @@ internal sealed interface OnboardingPaymentUiState { data class Content( val progress: OnboardingProgress, val payinStatus: OnboardingPayinStatus, + val connectedAnimationPlayed: Boolean = false, ) : OnboardingPaymentUiState } @@ -131,6 +138,8 @@ internal sealed interface OnboardingPaymentEvent { data object ConnectPayment : OnboardingPaymentEvent data object Continue : OnboardingPaymentEvent + + data object ConnectedAnimationCompleted : OnboardingPaymentEvent } @Composable @@ -155,6 +164,7 @@ internal fun OnboardingPaymentDestination(viewModel: OnboardingPaymentViewModel, onRetry = { viewModel.emit(OnboardingPaymentEvent.Retry) }, onConnectPayment = { viewModel.emit(OnboardingPaymentEvent.ConnectPayment) }, onContinue = { viewModel.emit(OnboardingPaymentEvent.Continue) }, + onConnectedAnimationCompleted = { viewModel.emit(OnboardingPaymentEvent.ConnectedAnimationCompleted) }, ) } @@ -167,6 +177,7 @@ private fun OnboardingPaymentScreen( onRetry: () -> Unit, onConnectPayment: () -> Unit, onContinue: () -> Unit, + onConnectedAnimationCompleted: () -> Unit, ) { OnboardingStepScaffold( progress = (uiState as? OnboardingPaymentUiState.Content)?.progress, @@ -210,11 +221,16 @@ private fun OnboardingPaymentScreen( ) } - // A payin method exists (active, or pending bank activation). The design's connected - // state is just the "switch accounts later" hint plus Continue; we deliberately make no - // claim about the connection being active, so pending and active render the same. + // A payin method exists (active, or pending bank activation). The connected graphic makes + // the "connected" claim about the payment method itself, not the mandate, so pending and + // active render the same. OnboardingPayinStatus.Pending, OnboardingPayinStatus.Active -> { - OnboardingStepHeader(title = stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_TITLE)) + Spacer(Modifier.weight(1f)) + OnboardingConnectingPaymentSymbol( + animationAlreadyPlayed = content.connectedAnimationPlayed, + onAnimationCompleted = onConnectedAnimationCompleted, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) Spacer(Modifier.weight(1f)) Spacer(Modifier.height(24.dp)) HedvigText( @@ -249,6 +265,7 @@ private fun PreviewOnboardingPaymentScreen( onRetry = {}, onConnectPayment = {}, onContinue = {}, + onConnectedAnimationCompleted = {}, ) } } @@ -265,10 +282,12 @@ private class OnboardingPaymentUiStateProvider : CollectionPreviewParameterProvi OnboardingPaymentUiState.Content( progress = OnboardingProgress(totalSteps = 5, currentIndex = 3), payinStatus = OnboardingPayinStatus.Pending, + connectedAnimationPlayed = true, ), OnboardingPaymentUiState.Content( progress = OnboardingProgress(totalSteps = 5, currentIndex = 3), payinStatus = OnboardingPayinStatus.Active, + connectedAnimationPlayed = true, ), ), ) From deb7f401f39fa5779dc3e4a79d1f0113ce1efcd8 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 5 Aug 2026 14:07:56 +0200 Subject: [PATCH 096/119] Show the connecting graphic from the start and keep the payment title Render the Bank + Hedvig graphic in the initial connect-payment state too (without the check), and keep the 'Connect payment' title across all states. The green check now shows only once a payment method is connected (pending or active). Fix the loader dots to pulse as an in-sync left-to-right wave using a fast-forward start offset, so they no longer drift out of phase over time. --- .../OnboardingConnectingPaymentSymbol.kt | 41 ++++++++-- .../payment/OnboardingPaymentDestination.kt | 82 ++++++++----------- 2 files changed, 68 insertions(+), 55 deletions(-) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingConnectingPaymentSymbol.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingConnectingPaymentSymbol.kt index 7f25452dd2..4d770e690b 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingConnectingPaymentSymbol.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingConnectingPaymentSymbol.kt @@ -3,6 +3,8 @@ package com.hedvig.android.feature.onboarding.ui.payment import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.StartOffset +import androidx.compose.animation.core.StartOffsetType import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.infiniteRepeatable @@ -53,20 +55,28 @@ private const val DotPulseDurationMillis = 600 private const val DotPulseStaggerMillis = 200 /** - * The connected-state graphic on the connect-payment step: a "Bank" card linked by pulsing dots to - * the Hedvig symbol, with a green checkmark that pops onto the symbol once the payment method is - * connected. The pop plays only the first time the connected state is seen (see - * [OnboardingPaymentScreen]); the dots pulse continuously. + * The graphic on the connect-payment step: a "Bank" card linked by pulsing dots to the Hedvig + * symbol. It shows from the start; once a payment method is connected ([showCheck]) a green + * checkmark pops onto the symbol, animating only the first time that state is seen (see + * [OnboardingPaymentScreen]). The dots pulse continuously. */ @Composable internal fun OnboardingConnectingPaymentSymbol( + showCheck: Boolean, animationAlreadyPlayed: Boolean, onAnimationCompleted: () -> Unit, modifier: Modifier = Modifier, ) { - var checkVisible by remember { mutableStateOf(animationAlreadyPlayed) } - LaunchedEffect(Unit) { - if (animationAlreadyPlayed) return@LaunchedEffect + var checkVisible by remember { mutableStateOf(showCheck && animationAlreadyPlayed) } + LaunchedEffect(showCheck) { + if (!showCheck) { + checkVisible = false + return@LaunchedEffect + } + if (animationAlreadyPlayed) { + checkVisible = true + return@LaunchedEffect + } delay(CheckPopDelayMillis.milliseconds) checkVisible = true onAnimationCompleted() @@ -113,8 +123,11 @@ private fun LoaderDots() { initialValue = 0.3f, targetValue = 1f, animationSpec = infiniteRepeatable( - animation = tween(DotPulseDurationMillis, delayMillis = index * DotPulseStaggerMillis, easing = LinearEasing), + animation = tween(DotPulseDurationMillis, easing = LinearEasing), repeatMode = RepeatMode.Reverse, + // Phase-shift each dot so the wave travels left→right; a fast-forward start offset keeps + // every dot on the same period, so they stay in sync instead of drifting apart over time. + initialStartOffset = StartOffset(index * DotPulseStaggerMillis, StartOffsetType.FastForward), ), label = "connecting dot $index", ) @@ -173,10 +186,20 @@ private fun HedvigSymbolWithCheck(checkVisible: Boolean) { @HedvigPreview @Composable -private fun PreviewOnboardingConnectingPaymentSymbol() { +private fun PreviewOnboardingConnectingPaymentSymbolConnected() { HedvigTheme { Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { ConnectingGraphic(checkVisible = true, modifier = Modifier.size(width = 210.dp, height = 74.dp)) } } } + +@HedvigPreview +@Composable +private fun PreviewOnboardingConnectingPaymentSymbolInitial() { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + ConnectingGraphic(checkVisible = false, modifier = Modifier.size(width = 210.dp, height = 74.dp)) + } + } +} diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt index ea3b5b8d9e..e915128ec7 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt @@ -198,53 +198,43 @@ private fun OnboardingPaymentScreen( } is OnboardingPaymentUiState.Content -> { + // A payin method exists once the status is pending or active (pending is a bank activation + // still settling); either way the user has connected a method, so both show the check. + val isConnected = content.payinStatus != OnboardingPayinStatus.NeedsSetup Spacer(Modifier.height(16.dp)) - when (content.payinStatus) { - OnboardingPayinStatus.NeedsSetup -> { - OnboardingStepHeader( - title = stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_TITLE), - description = stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_SUBTITLE), - ) - Spacer(Modifier.weight(1f)) - Spacer(Modifier.height(24.dp)) - HedvigText( - text = stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_FOOTNOTE), - style = HedvigTheme.typography.label, - color = HedvigTheme.colorScheme.textSecondary, - modifier = Modifier - .align(Alignment.CenterHorizontally) - .padding(horizontal = 32.dp), - ) - OnboardingStepButtons( - primaryText = stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_TITLE), - onPrimaryClick = onConnectPayment, - ) - } - - // A payin method exists (active, or pending bank activation). The connected graphic makes - // the "connected" claim about the payment method itself, not the mandate, so pending and - // active render the same. - OnboardingPayinStatus.Pending, OnboardingPayinStatus.Active -> { - Spacer(Modifier.weight(1f)) - OnboardingConnectingPaymentSymbol( - animationAlreadyPlayed = content.connectedAnimationPlayed, - onAnimationCompleted = onConnectedAnimationCompleted, - modifier = Modifier.align(Alignment.CenterHorizontally), - ) - Spacer(Modifier.weight(1f)) - Spacer(Modifier.height(24.dp)) - HedvigText( - text = stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_SWITCH_ACCOUNTS_LATER), - style = HedvigTheme.typography.label, - color = HedvigTheme.colorScheme.textSecondary, - modifier = Modifier.align(Alignment.CenterHorizontally), - ) - OnboardingStepButtons( - primaryText = stringResource(Res.string.general_continue_button), - onPrimaryClick = onContinue, - ) - } - } + OnboardingStepHeader( + title = stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_TITLE), + description = if (isConnected) null else stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_SUBTITLE), + ) + Spacer(Modifier.weight(1f)) + OnboardingConnectingPaymentSymbol( + showCheck = isConnected, + animationAlreadyPlayed = content.connectedAnimationPlayed, + onAnimationCompleted = onConnectedAnimationCompleted, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + Spacer(Modifier.weight(1f)) + Spacer(Modifier.height(24.dp)) + HedvigText( + text = if (isConnected) { + stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_SWITCH_ACCOUNTS_LATER) + } else { + stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_FOOTNOTE) + }, + style = HedvigTheme.typography.label, + color = HedvigTheme.colorScheme.textSecondary, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(horizontal = 32.dp), + ) + OnboardingStepButtons( + primaryText = if (isConnected) { + stringResource(Res.string.general_continue_button) + } else { + stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_TITLE) + }, + onPrimaryClick = if (isConnected) onContinue else onConnectPayment, + ) } } } From 389c1a9c4c3fa0f53173faede745139af8bb670a Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 5 Aug 2026 15:32:20 +0200 Subject: [PATCH 097/119] Use the shared ThreeDotsLoading in the payment graphic and keep the subtitle Replace the bespoke inline loader dots with the design system's ThreeDotsLoading, so the payment connecting graphic matches the dot animation used elsewhere (chat, buttons, progress) and waves left-to-right instead of backwards. Keep the connect- payment subtitle in every state so the header height, and the graphic below it, stay put as the status changes. --- .../OnboardingConnectingPaymentSymbol.kt | 36 ++----------------- .../payment/OnboardingPaymentDestination.kt | 4 ++- 2 files changed, 6 insertions(+), 34 deletions(-) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingConnectingPaymentSymbol.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingConnectingPaymentSymbol.kt index 4d770e690b..e3fe633714 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingConnectingPaymentSymbol.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingConnectingPaymentSymbol.kt @@ -35,6 +35,7 @@ import com.hedvig.android.design.system.hedvig.HedvigText import com.hedvig.android.design.system.hedvig.HedvigTheme import com.hedvig.android.design.system.hedvig.Icon import com.hedvig.android.design.system.hedvig.Surface +import com.hedvig.android.design.system.hedvig.ThreeDotsLoading import com.hedvig.android.design.system.hedvig.hedvigDropShadow import com.hedvig.android.design.system.hedvig.icon.Checkmark import com.hedvig.android.design.system.hedvig.icon.HedvigIcons @@ -47,12 +48,9 @@ import org.jetbrains.compose.resources.painterResource private val SymbolSize = 74.dp private val CheckBadgeSize = 24.dp -private val DotSize = 6.dp -// Timing for the connected-state graphic, grouped for easy tuning with design. +// Delay before the connected check pops in, grouped for easy tuning with design. private const val CheckPopDelayMillis = 400L -private const val DotPulseDurationMillis = 600 -private const val DotPulseStaggerMillis = 200 /** * The graphic on the connect-payment step: a "Bank" card linked by pulsing dots to the Hedvig @@ -92,7 +90,7 @@ private fun ConnectingGraphic(checkVisible: Boolean, modifier: Modifier = Modifi horizontalArrangement = Arrangement.spacedBy(16.dp), ) { BankCard() - LoaderDots() + ThreeDotsLoading() HedvigSymbolWithCheck(checkVisible = checkVisible) } } @@ -114,34 +112,6 @@ private fun BankCard() { } } -@Composable -private fun LoaderDots() { - val transition = rememberInfiniteTransition(label = "connecting loader dots") - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - repeat(3) { index -> - val alpha by transition.animateFloat( - initialValue = 0.3f, - targetValue = 1f, - animationSpec = infiniteRepeatable( - animation = tween(DotPulseDurationMillis, easing = LinearEasing), - repeatMode = RepeatMode.Reverse, - // Phase-shift each dot so the wave travels left→right; a fast-forward start offset keeps - // every dot on the same period, so they stay in sync instead of drifting apart over time. - initialStartOffset = StartOffset(index * DotPulseStaggerMillis, StartOffsetType.FastForward), - ), - label = "connecting dot $index", - ) - Box( - Modifier - .size(DotSize) - .graphicsLayer { this.alpha = alpha } - .clip(CircleShape) - .background(HedvigTheme.colorScheme.fillPrimary), - ) - } - } -} - @Composable private fun HedvigSymbolWithCheck(checkVisible: Boolean) { val checkScale by animateFloatAsState( diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt index e915128ec7..e674ca4ae9 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt @@ -202,9 +202,11 @@ private fun OnboardingPaymentScreen( // still settling); either way the user has connected a method, so both show the check. val isConnected = content.payinStatus != OnboardingPayinStatus.NeedsSetup Spacer(Modifier.height(16.dp)) + // Keep the subtitle in every state so the header height (and the graphic below it) never + // shifts vertically as the status changes. OnboardingStepHeader( title = stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_TITLE), - description = if (isConnected) null else stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_SUBTITLE), + description = stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_SUBTITLE), ) Spacer(Modifier.weight(1f)) OnboardingConnectingPaymentSymbol( From 20cedfaa9a1d476814422e38b34c82b216f4ad73 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 5 Aug 2026 15:52:09 +0200 Subject: [PATCH 098/119] Make ThreeDotsLoading a smooth continuous wave Replace the keyframed dip-and-hold (RepeatMode.Restart, EaseIn/EaseOut, a stable hold each loop) with a continuous RepeatMode.Reverse breath and a stagger shorter than the breath, so the dots overlap into a soft wave with no velocity reset instead of blinking one at a time. Delay each dot by its index so the wave travels left to right. Same public API and defaults, so every caller (chat, buttons, progress, notifications, the onboarding payment graphic) gets the smoother animation. --- .../design/system/hedvig/ThreeDotsLoading.kt | 99 ++++++------------- 1 file changed, 30 insertions(+), 69 deletions(-) diff --git a/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/ThreeDotsLoading.kt b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/ThreeDotsLoading.kt index b982ce50a9..4d51ac306d 100644 --- a/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/ThreeDotsLoading.kt +++ b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/ThreeDotsLoading.kt @@ -1,15 +1,12 @@ package com.hedvig.android.design.system.hedvig -import androidx.compose.animation.animateColor -import androidx.compose.animation.core.EaseIn -import androidx.compose.animation.core.EaseOut -import androidx.compose.animation.core.InfiniteRepeatableSpec +import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.StartOffset import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.keyframes import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -18,11 +15,11 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.lerp import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -37,80 +34,44 @@ fun ThreeDotsLoading( temporaryScale: Float = 0.9f, circleRadius: Dp = 6.dp, ) { + val transition = rememberInfiniteTransition() Row( horizontalArrangement = Arrangement.spacedBy(circleRadius), modifier = modifier, ) { for (index in (0.. dotInfiniteRepeatableSpec( - stableValue: T, - temporaryValue: T, - startDelay: Int, -): InfiniteRepeatableSpec = infiniteRepeatable( - animation = keyframes { - durationMillis = animationDurationMilliseconds * numberOfDots - stableValue at 0 - temporaryValue at animationDurationMilliseconds using EaseIn - stableValue at animationDurationMilliseconds * 2 using EaseOut - stableValue at durationMillis - }, - repeatMode = RepeatMode.Restart, - initialStartOffset = StartOffset(startDelay), -) +// One dot's travel from stable to temporary; RepeatMode.Reverse doubles this into a full breath. +private const val dotBreathDurationMilliseconds = 600 -// The duration of half of the animation. During this duration, one item does the "out" animation, and at the same -// time, the next item does the "in" animation, they happen in parallel. -private const val animationDurationMilliseconds = 500 +// Shorter than the breath so consecutive dots overlap into a wave instead of blinking one at a time. +private const val dotStaggerMilliseconds = 200 private const val numberOfDots = 3 // @HedvigPreview From 4f91057425100cd1794b01328406f960c4c2a9b4 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 5 Aug 2026 16:07:14 +0200 Subject: [PATCH 099/119] Remove "opens in your browser" not present in the altest design --- .../onboarding/ui/bundle/OnboardingBundleDestination.kt | 9 --------- 1 file changed, 9 deletions(-) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt index b227acc4db..6ea68675b7 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt @@ -196,15 +196,6 @@ private fun OnboardingBundleScreen( ) } Spacer(Modifier.height(8.dp)) - HedvigText( - text = stringResource(Res.string.ONBOARDING_OPENS_IN_BROWSER_HINT), - style = HedvigTheme.typography.finePrint, - color = HedvigTheme.colorScheme.textSecondary, - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - ) Spacer(Modifier.weight(1f)) Spacer(Modifier.height(24.dp)) OnboardingStepButtons( From 32976ba62dca2be88aa5d1346a8eda14448ad932 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 5 Aug 2026 16:33:15 +0200 Subject: [PATCH 100/119] Drive the payment check from live status and always allow skipping Remove the persisted connectedAnimationPlayed flag (and its event/state) so the connected check is derived purely from the live payinStatus; moving forward and back no longer carries any connection-adjacent state, it just reflects pending/active or not. Add a 'Do this later' skip on the not-yet-connected state so the payment step is always optional. --- .../OnboardingConnectingPaymentSymbol.kt | 20 ++++---------- .../payment/OnboardingPaymentDestination.kt | 27 +++++++------------ 2 files changed, 14 insertions(+), 33 deletions(-) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingConnectingPaymentSymbol.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingConnectingPaymentSymbol.kt index e3fe633714..97343bd4c5 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingConnectingPaymentSymbol.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingConnectingPaymentSymbol.kt @@ -54,30 +54,20 @@ private const val CheckPopDelayMillis = 400L /** * The graphic on the connect-payment step: a "Bank" card linked by pulsing dots to the Hedvig - * symbol. It shows from the start; once a payment method is connected ([showCheck]) a green - * checkmark pops onto the symbol, animating only the first time that state is seen (see - * [OnboardingPaymentScreen]). The dots pulse continuously. + * symbol. It shows from the start; while [showCheck] is true (a payment method is connected) a green + * checkmark pops onto the symbol. [showCheck] tracks the live status directly, so the check appears + * only while the backend actually reports a connected method. The dots pulse continuously. */ @Composable -internal fun OnboardingConnectingPaymentSymbol( - showCheck: Boolean, - animationAlreadyPlayed: Boolean, - onAnimationCompleted: () -> Unit, - modifier: Modifier = Modifier, -) { - var checkVisible by remember { mutableStateOf(showCheck && animationAlreadyPlayed) } +internal fun OnboardingConnectingPaymentSymbol(showCheck: Boolean, modifier: Modifier = Modifier) { + var checkVisible by remember { mutableStateOf(false) } LaunchedEffect(showCheck) { if (!showCheck) { checkVisible = false return@LaunchedEffect } - if (animationAlreadyPlayed) { - checkVisible = true - return@LaunchedEffect - } delay(CheckPopDelayMillis.milliseconds) checkVisible = true - onAnimationCompleted() } ConnectingGraphic(checkVisible = checkVisible, modifier = modifier) } diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt index e674ca4ae9..c8e68ccf40 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt @@ -44,6 +44,7 @@ import hedvig.resources.ONBOARDING_CONNECT_PAYMENT_FOOTNOTE import hedvig.resources.ONBOARDING_CONNECT_PAYMENT_SUBTITLE import hedvig.resources.ONBOARDING_CONNECT_PAYMENT_SWITCH_ACCOUNTS_LATER import hedvig.resources.ONBOARDING_CONNECT_PAYMENT_TITLE +import hedvig.resources.ONBOARDING_DO_THIS_LATER_BUTTON import hedvig.resources.Res import hedvig.resources.general_continue_button import kotlinx.coroutines.launch @@ -70,7 +71,6 @@ internal class OnboardingPaymentPresenter( ): OnboardingPaymentUiState { var currentState by remember { mutableStateOf(lastState) } var loadIteration by remember { mutableIntStateOf(0) } - var connectedAnimationPlayed by remember { mutableStateOf(false) } LaunchedEffect(loadIteration) { if (currentState is OnboardingPaymentUiState.Content) return@LaunchedEffect @@ -104,15 +104,10 @@ internal class OnboardingPaymentPresenter( OnboardingPaymentEvent.ConnectPayment -> navigator.openConnectPayment() OnboardingPaymentEvent.Continue -> launch { navigator.continueFrom(OnboardingStepId.ConnectPayment) } - - OnboardingPaymentEvent.ConnectedAnimationCompleted -> connectedAnimationPlayed = true } } - return when (val state = currentState) { - is OnboardingPaymentUiState.Content -> state.copy(connectedAnimationPlayed = connectedAnimationPlayed) - else -> state - } + return currentState } } @@ -124,7 +119,6 @@ internal sealed interface OnboardingPaymentUiState { data class Content( val progress: OnboardingProgress, val payinStatus: OnboardingPayinStatus, - val connectedAnimationPlayed: Boolean = false, ) : OnboardingPaymentUiState } @@ -138,8 +132,6 @@ internal sealed interface OnboardingPaymentEvent { data object ConnectPayment : OnboardingPaymentEvent data object Continue : OnboardingPaymentEvent - - data object ConnectedAnimationCompleted : OnboardingPaymentEvent } @Composable @@ -164,7 +156,6 @@ internal fun OnboardingPaymentDestination(viewModel: OnboardingPaymentViewModel, onRetry = { viewModel.emit(OnboardingPaymentEvent.Retry) }, onConnectPayment = { viewModel.emit(OnboardingPaymentEvent.ConnectPayment) }, onContinue = { viewModel.emit(OnboardingPaymentEvent.Continue) }, - onConnectedAnimationCompleted = { viewModel.emit(OnboardingPaymentEvent.ConnectedAnimationCompleted) }, ) } @@ -177,7 +168,6 @@ private fun OnboardingPaymentScreen( onRetry: () -> Unit, onConnectPayment: () -> Unit, onContinue: () -> Unit, - onConnectedAnimationCompleted: () -> Unit, ) { OnboardingStepScaffold( progress = (uiState as? OnboardingPaymentUiState.Content)?.progress, @@ -199,7 +189,9 @@ private fun OnboardingPaymentScreen( is OnboardingPaymentUiState.Content -> { // A payin method exists once the status is pending or active (pending is a bank activation - // still settling); either way the user has connected a method, so both show the check. + // still settling); either way the user has connected a method, so both show the check. This + // is derived straight from the live status, so moving forward and back never claims a + // connection the backend does not report. val isConnected = content.payinStatus != OnboardingPayinStatus.NeedsSetup Spacer(Modifier.height(16.dp)) // Keep the subtitle in every state so the header height (and the graphic below it) never @@ -211,8 +203,6 @@ private fun OnboardingPaymentScreen( Spacer(Modifier.weight(1f)) OnboardingConnectingPaymentSymbol( showCheck = isConnected, - animationAlreadyPlayed = content.connectedAnimationPlayed, - onAnimationCompleted = onConnectedAnimationCompleted, modifier = Modifier.align(Alignment.CenterHorizontally), ) Spacer(Modifier.weight(1f)) @@ -236,6 +226,10 @@ private fun OnboardingPaymentScreen( stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_TITLE) }, onPrimaryClick = if (isConnected) onContinue else onConnectPayment, + // Connecting a payment method is always optional, so the not-yet-connected state offers a + // skip. Once connected the primary button already continues, so no secondary is needed. + secondaryText = if (isConnected) null else stringResource(Res.string.ONBOARDING_DO_THIS_LATER_BUTTON), + onSecondaryClick = if (isConnected) null else onContinue, ) } } @@ -257,7 +251,6 @@ private fun PreviewOnboardingPaymentScreen( onRetry = {}, onConnectPayment = {}, onContinue = {}, - onConnectedAnimationCompleted = {}, ) } } @@ -274,12 +267,10 @@ private class OnboardingPaymentUiStateProvider : CollectionPreviewParameterProvi OnboardingPaymentUiState.Content( progress = OnboardingProgress(totalSteps = 5, currentIndex = 3), payinStatus = OnboardingPayinStatus.Pending, - connectedAnimationPlayed = true, ), OnboardingPaymentUiState.Content( progress = OnboardingProgress(totalSteps = 5, currentIndex = 3), payinStatus = OnboardingPayinStatus.Active, - connectedAnimationPlayed = true, ), ), ) From bcc679217120369f97993944946629795f7b4413 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 5 Aug 2026 17:18:03 +0200 Subject: [PATCH 101/119] Render the connected check in payment previews Seed the check as shown when in inspection mode, so the pending and active preview states render the connected graphic instead of looking like the not-connected one. --- .../ui/payment/OnboardingConnectingPaymentSymbol.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingConnectingPaymentSymbol.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingConnectingPaymentSymbol.kt index 97343bd4c5..cd465de0bf 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingConnectingPaymentSymbol.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingConnectingPaymentSymbol.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.unit.dp import com.hedvig.android.design.system.hedvig.HedvigPreview import com.hedvig.android.design.system.hedvig.HedvigText @@ -60,7 +61,10 @@ private const val CheckPopDelayMillis = 400L */ @Composable internal fun OnboardingConnectingPaymentSymbol(showCheck: Boolean, modifier: Modifier = Modifier) { - var checkVisible by remember { mutableStateOf(false) } + // In a static preview the pop's delay never elapses, so seed the check as already shown there to + // keep the connected states visible; at runtime it starts hidden and pops in. + val inPreview = LocalInspectionMode.current + var checkVisible by remember { mutableStateOf(inPreview && showCheck) } LaunchedEffect(showCheck) { if (!showCheck) { checkVisible = false From 437566274005f0836ec6625dbbc250b3a5d31e12 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 5 Aug 2026 17:40:38 +0200 Subject: [PATCH 102/119] Clean up after code review Remove the unused imports the ViewModel/Destination split left behind (ktlint no longer removes unused imports, so the full copied import blocks lingered), and the stale animation imports in the payment symbol. Unify the theme step's preview with the other screens (a UiState provider covering loading/error/content, non-null progressAnimation) and reword two comments that referenced the design process or a rejected alternative. --- .../design/system/hedvig/ThreeDotsLoading.kt | 2 +- .../ui/bundle/OnboardingBundleDestination.kt | 2 - .../OnboardingCoInsuredDestination.kt | 14 ----- .../coinsured/OnboardingCoInsuredViewModel.kt | 32 ----------- .../ui/invite/OnboardingInviteDestination.kt | 12 ---- .../ui/invite/OnboardingInviteViewModel.kt | 44 -------------- .../OnboardingConnectingPaymentSymbol.kt | 9 +-- .../ui/petid/OnboardingPetIdDestination.kt | 14 ----- .../ui/petid/OnboardingPetIdViewModel.kt | 31 ---------- .../ui/phone/OnboardingPhoneDestination.kt | 14 ----- .../ui/phone/OnboardingPhoneViewModel.kt | 27 --------- .../ui/theme/OnboardingThemeDestination.kt | 57 +++++++++---------- .../ui/theme/OnboardingThemeViewModel.kt | 46 --------------- 13 files changed, 30 insertions(+), 274 deletions(-) diff --git a/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/ThreeDotsLoading.kt b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/ThreeDotsLoading.kt index 4d51ac306d..1942a34cb4 100644 --- a/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/ThreeDotsLoading.kt +++ b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/ThreeDotsLoading.kt @@ -70,7 +70,7 @@ fun ThreeDotsLoading( // One dot's travel from stable to temporary; RepeatMode.Reverse doubles this into a full breath. private const val dotBreathDurationMilliseconds = 600 -// Shorter than the breath so consecutive dots overlap into a wave instead of blinking one at a time. +// Shorter than the breath so consecutive dots overlap into a wave. private const val dotStaggerMilliseconds = 200 private const val numberOfDots = 3 diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt index 6ea68675b7..c21ffbad0c 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt @@ -19,7 +19,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider import androidx.compose.ui.unit.dp @@ -55,7 +54,6 @@ import dev.zacsweers.metro.Inject import hedvig.resources.ONBOARDING_CONTINUE_TO_APP_BUTTON import hedvig.resources.ONBOARDING_CROSS_SELL_SUBTITLE import hedvig.resources.ONBOARDING_CROSS_SELL_TITLE -import hedvig.resources.ONBOARDING_OPENS_IN_BROWSER_HINT import hedvig.resources.ONBOARDING_SEE_PRICE_BUTTON import hedvig.resources.Res import kotlinx.coroutines.launch diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt index 6c9d731136..bbaf5045b6 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt @@ -7,9 +7,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable @@ -21,8 +19,6 @@ import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameter import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.hedvig.android.core.common.di.ActivityRetainedScope -import com.hedvig.android.core.common.di.HedvigViewModel import com.hedvig.android.data.coinsured.CoInsuredFlowType import com.hedvig.android.design.system.hedvig.HedvigErrorSection import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgressDebounced @@ -30,21 +26,12 @@ import com.hedvig.android.design.system.hedvig.HedvigPreview import com.hedvig.android.design.system.hedvig.HedvigText import com.hedvig.android.design.system.hedvig.HedvigTheme import com.hedvig.android.design.system.hedvig.Surface -import com.hedvig.android.feature.onboarding.data.OnboardingSession -import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore -import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator -import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId import com.hedvig.android.feature.onboarding.ui.OnboardingContractCard import com.hedvig.android.feature.onboarding.ui.OnboardingProgress import com.hedvig.android.feature.onboarding.ui.OnboardingProgressBarAnimation import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons import com.hedvig.android.feature.onboarding.ui.OnboardingStepHeader import com.hedvig.android.feature.onboarding.ui.OnboardingStepScaffold -import com.hedvig.android.feature.onboarding.ui.progressFor -import com.hedvig.android.molecule.public.MoleculePresenter -import com.hedvig.android.molecule.public.MoleculePresenterScope -import com.hedvig.android.molecule.public.MoleculeViewModel -import dev.zacsweers.metro.Inject import hedvig.resources.ONBOARDING_ADD_COINSURED_TITLE import hedvig.resources.ONBOARDING_ADD_COOWNERS_TITLE import hedvig.resources.ONBOARDING_ADD_INFO_LATER_LABEL @@ -52,7 +39,6 @@ import hedvig.resources.ONBOARDING_DO_THIS_LATER_BUTTON import hedvig.resources.ONBOARDING_MISSING_INFO_SUBTITLE import hedvig.resources.Res import hedvig.resources.general_continue_button -import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource @Composable diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredViewModel.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredViewModel.kt index b959e2a429..f93d7b71d7 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredViewModel.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredViewModel.kt @@ -1,59 +1,27 @@ package com.hedvig.android.feature.onboarding.ui.coinsured -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.LifecycleResumeEffect -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.hedvig.android.core.common.di.ActivityRetainedScope import com.hedvig.android.core.common.di.HedvigViewModel import com.hedvig.android.data.coinsured.CoInsuredFlowType -import com.hedvig.android.design.system.hedvig.HedvigErrorSection -import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgressDebounced -import com.hedvig.android.design.system.hedvig.HedvigPreview -import com.hedvig.android.design.system.hedvig.HedvigText -import com.hedvig.android.design.system.hedvig.HedvigTheme -import com.hedvig.android.design.system.hedvig.Surface import com.hedvig.android.feature.onboarding.data.OnboardingSession import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId -import com.hedvig.android.feature.onboarding.ui.OnboardingContractCard import com.hedvig.android.feature.onboarding.ui.OnboardingProgress import com.hedvig.android.feature.onboarding.ui.OnboardingProgressBarAnimation -import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons -import com.hedvig.android.feature.onboarding.ui.OnboardingStepHeader -import com.hedvig.android.feature.onboarding.ui.OnboardingStepScaffold import com.hedvig.android.feature.onboarding.ui.progressFor import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel import dev.zacsweers.metro.Inject -import hedvig.resources.ONBOARDING_ADD_COINSURED_TITLE -import hedvig.resources.ONBOARDING_ADD_COOWNERS_TITLE -import hedvig.resources.ONBOARDING_ADD_INFO_LATER_LABEL -import hedvig.resources.ONBOARDING_DO_THIS_LATER_BUTTON -import hedvig.resources.ONBOARDING_MISSING_INFO_SUBTITLE -import hedvig.resources.Res -import hedvig.resources.general_continue_button import kotlinx.coroutines.launch -import org.jetbrains.compose.resources.stringResource @Inject @HedvigViewModel(ActivityRetainedScope::class) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt index effecbb5d7..37214a8c56 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt @@ -21,7 +21,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -31,8 +30,6 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.hedvig.android.core.common.di.ActivityRetainedScope -import com.hedvig.android.core.common.di.HedvigViewModel import com.hedvig.android.design.system.hedvig.DividerPosition import com.hedvig.android.design.system.hedvig.HedvigErrorSection import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgressDebounced @@ -43,26 +40,17 @@ import com.hedvig.android.design.system.hedvig.Surface import com.hedvig.android.design.system.hedvig.hedvigDropShadow import com.hedvig.android.design.system.hedvig.horizontalDivider import com.hedvig.android.design.system.hedvig.tokens.MotionTokens -import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore -import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator -import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId import com.hedvig.android.feature.onboarding.ui.OnboardingProgress import com.hedvig.android.feature.onboarding.ui.OnboardingProgressBarAnimation import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons import com.hedvig.android.feature.onboarding.ui.OnboardingStepHeader import com.hedvig.android.feature.onboarding.ui.OnboardingStepScaffold -import com.hedvig.android.feature.onboarding.ui.progressFor -import com.hedvig.android.molecule.public.MoleculePresenter -import com.hedvig.android.molecule.public.MoleculePresenterScope -import com.hedvig.android.molecule.public.MoleculeViewModel -import dev.zacsweers.metro.Inject import hedvig.resources.ONBOARDING_INVITE_FRIEND_SUBTITLE import hedvig.resources.ONBOARDING_INVITE_FRIEND_TITLE import hedvig.resources.Res import hedvig.resources.general_continue_button import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.delay -import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource @Composable diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteViewModel.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteViewModel.kt index 9298a61cfb..e0a94a357b 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteViewModel.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteViewModel.kt @@ -1,22 +1,5 @@ package com.hedvig.android.feature.onboarding.ui.invite -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.TweenSpec -import androidx.compose.animation.core.tween -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -24,46 +7,19 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.hedvig.android.core.common.di.ActivityRetainedScope import com.hedvig.android.core.common.di.HedvigViewModel -import com.hedvig.android.design.system.hedvig.DividerPosition -import com.hedvig.android.design.system.hedvig.HedvigErrorSection -import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgressDebounced -import com.hedvig.android.design.system.hedvig.HedvigPreview -import com.hedvig.android.design.system.hedvig.HedvigText -import com.hedvig.android.design.system.hedvig.HedvigTheme -import com.hedvig.android.design.system.hedvig.Surface -import com.hedvig.android.design.system.hedvig.hedvigDropShadow -import com.hedvig.android.design.system.hedvig.horizontalDivider -import com.hedvig.android.design.system.hedvig.tokens.MotionTokens import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId import com.hedvig.android.feature.onboarding.ui.OnboardingProgress import com.hedvig.android.feature.onboarding.ui.OnboardingProgressBarAnimation -import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons -import com.hedvig.android.feature.onboarding.ui.OnboardingStepHeader -import com.hedvig.android.feature.onboarding.ui.OnboardingStepScaffold import com.hedvig.android.feature.onboarding.ui.progressFor import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel import dev.zacsweers.metro.Inject -import hedvig.resources.ONBOARDING_INVITE_FRIEND_SUBTITLE -import hedvig.resources.ONBOARDING_INVITE_FRIEND_TITLE -import hedvig.resources.Res -import hedvig.resources.general_continue_button -import kotlin.time.Duration.Companion.milliseconds -import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import org.jetbrains.compose.resources.stringResource @Inject @HedvigViewModel(ActivityRetainedScope::class) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingConnectingPaymentSymbol.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingConnectingPaymentSymbol.kt index cd465de0bf..60af9df764 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingConnectingPaymentSymbol.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingConnectingPaymentSymbol.kt @@ -1,14 +1,7 @@ package com.hedvig.android.feature.onboarding.ui.payment -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.StartOffset -import androidx.compose.animation.core.StartOffsetType -import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.foundation.Image @@ -50,7 +43,7 @@ import org.jetbrains.compose.resources.painterResource private val SymbolSize = 74.dp private val CheckBadgeSize = 24.dp -// Delay before the connected check pops in, grouped for easy tuning with design. +// How long after the graphic appears the connected check pops in. private const val CheckPopDelayMillis = 400L /** diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdDestination.kt index b51011e1bb..b87bbd86c7 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdDestination.kt @@ -7,9 +7,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable @@ -21,36 +19,24 @@ import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameter import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.hedvig.android.core.common.di.ActivityRetainedScope -import com.hedvig.android.core.common.di.HedvigViewModel import com.hedvig.android.design.system.hedvig.HedvigErrorSection import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgressDebounced import com.hedvig.android.design.system.hedvig.HedvigPreview import com.hedvig.android.design.system.hedvig.HedvigText import com.hedvig.android.design.system.hedvig.HedvigTheme import com.hedvig.android.design.system.hedvig.Surface -import com.hedvig.android.feature.onboarding.data.OnboardingSession -import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore -import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator -import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId import com.hedvig.android.feature.onboarding.ui.OnboardingContractCard import com.hedvig.android.feature.onboarding.ui.OnboardingProgress import com.hedvig.android.feature.onboarding.ui.OnboardingProgressBarAnimation import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons import com.hedvig.android.feature.onboarding.ui.OnboardingStepHeader import com.hedvig.android.feature.onboarding.ui.OnboardingStepScaffold -import com.hedvig.android.feature.onboarding.ui.progressFor -import com.hedvig.android.molecule.public.MoleculePresenter -import com.hedvig.android.molecule.public.MoleculePresenterScope -import com.hedvig.android.molecule.public.MoleculeViewModel -import dev.zacsweers.metro.Inject import hedvig.resources.ONBOARDING_ADD_INFO_LATER_LABEL import hedvig.resources.ONBOARDING_ADD_PET_CHIP_IDS_TITLE import hedvig.resources.ONBOARDING_DO_THIS_LATER_BUTTON import hedvig.resources.ONBOARDING_MISSING_INFO_PET_SUBTITLE import hedvig.resources.Res import hedvig.resources.general_continue_button -import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource @Composable diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdViewModel.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdViewModel.kt index ea34212248..326afc4dbd 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdViewModel.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdViewModel.kt @@ -1,57 +1,26 @@ package com.hedvig.android.feature.onboarding.ui.petid -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.LifecycleResumeEffect -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.hedvig.android.core.common.di.ActivityRetainedScope import com.hedvig.android.core.common.di.HedvigViewModel -import com.hedvig.android.design.system.hedvig.HedvigErrorSection -import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgressDebounced -import com.hedvig.android.design.system.hedvig.HedvigPreview -import com.hedvig.android.design.system.hedvig.HedvigText -import com.hedvig.android.design.system.hedvig.HedvigTheme -import com.hedvig.android.design.system.hedvig.Surface import com.hedvig.android.feature.onboarding.data.OnboardingSession import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId -import com.hedvig.android.feature.onboarding.ui.OnboardingContractCard import com.hedvig.android.feature.onboarding.ui.OnboardingProgress import com.hedvig.android.feature.onboarding.ui.OnboardingProgressBarAnimation -import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons -import com.hedvig.android.feature.onboarding.ui.OnboardingStepHeader -import com.hedvig.android.feature.onboarding.ui.OnboardingStepScaffold import com.hedvig.android.feature.onboarding.ui.progressFor import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel import dev.zacsweers.metro.Inject -import hedvig.resources.ONBOARDING_ADD_INFO_LATER_LABEL -import hedvig.resources.ONBOARDING_ADD_PET_CHIP_IDS_TITLE -import hedvig.resources.ONBOARDING_DO_THIS_LATER_BUTTON -import hedvig.resources.ONBOARDING_MISSING_INFO_PET_SUBTITLE -import hedvig.resources.Res -import hedvig.resources.general_continue_button import kotlinx.coroutines.launch -import org.jetbrains.compose.resources.stringResource @Inject @HedvigViewModel(ActivityRetainedScope::class) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt index 89378dc575..4603905d35 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt @@ -9,8 +9,6 @@ import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow @@ -21,8 +19,6 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.hedvig.android.core.common.di.ActivityRetainedScope -import com.hedvig.android.core.common.di.HedvigViewModel import com.hedvig.android.design.system.hedvig.HedvigErrorSection import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgressDebounced import com.hedvig.android.design.system.hedvig.HedvigPreview @@ -30,20 +26,11 @@ import com.hedvig.android.design.system.hedvig.HedvigTextField import com.hedvig.android.design.system.hedvig.HedvigTextFieldDefaults import com.hedvig.android.design.system.hedvig.HedvigTheme import com.hedvig.android.design.system.hedvig.Surface -import com.hedvig.android.feature.onboarding.data.OnboardingRepository -import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore -import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator -import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId import com.hedvig.android.feature.onboarding.ui.OnboardingProgress import com.hedvig.android.feature.onboarding.ui.OnboardingProgressBarAnimation import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons import com.hedvig.android.feature.onboarding.ui.OnboardingStepHeader import com.hedvig.android.feature.onboarding.ui.OnboardingStepScaffold -import com.hedvig.android.feature.onboarding.ui.progressFor -import com.hedvig.android.molecule.public.MoleculePresenter -import com.hedvig.android.molecule.public.MoleculePresenterScope -import com.hedvig.android.molecule.public.MoleculeViewModel -import dev.zacsweers.metro.Inject import hedvig.resources.ONBOARDING_DO_THIS_LATER_BUTTON import hedvig.resources.ONBOARDING_PHONE_SAVE_ERROR import hedvig.resources.ONBOARDING_PHONE_SUBTITLE @@ -51,7 +38,6 @@ import hedvig.resources.ONBOARDING_PHONE_TITLE import hedvig.resources.Res import hedvig.resources.general_save_button import kotlinx.coroutines.flow.drop -import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource @Composable diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneViewModel.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneViewModel.kt index 852a0bb29b..923ee8d017 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneViewModel.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneViewModel.kt @@ -1,11 +1,5 @@ package com.hedvig.android.feature.onboarding.ui.phone -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -13,41 +7,20 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.hedvig.android.core.common.di.ActivityRetainedScope import com.hedvig.android.core.common.di.HedvigViewModel -import com.hedvig.android.design.system.hedvig.HedvigErrorSection -import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgressDebounced -import com.hedvig.android.design.system.hedvig.HedvigTextField -import com.hedvig.android.design.system.hedvig.HedvigTextFieldDefaults import com.hedvig.android.feature.onboarding.data.OnboardingRepository import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId import com.hedvig.android.feature.onboarding.ui.OnboardingProgress import com.hedvig.android.feature.onboarding.ui.OnboardingProgressBarAnimation -import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons -import com.hedvig.android.feature.onboarding.ui.OnboardingStepHeader -import com.hedvig.android.feature.onboarding.ui.OnboardingStepScaffold import com.hedvig.android.feature.onboarding.ui.progressFor import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel import dev.zacsweers.metro.Inject -import hedvig.resources.ONBOARDING_DO_THIS_LATER_BUTTON -import hedvig.resources.ONBOARDING_PHONE_SAVE_ERROR -import hedvig.resources.ONBOARDING_PHONE_SUBTITLE -import hedvig.resources.ONBOARDING_PHONE_TITLE -import hedvig.resources.Res -import hedvig.resources.general_save_button -import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch -import org.jetbrains.compose.resources.stringResource @Inject @HedvigViewModel(ActivityRetainedScope::class) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt index 3587688f97..7ed3e79a9c 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt @@ -14,11 +14,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.selection.selectable import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -29,11 +25,10 @@ import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.hedvig.android.core.common.di.ActivityRetainedScope -import com.hedvig.android.core.common.di.HedvigViewModel -import com.hedvig.android.data.settings.datastore.SettingsDataStore import com.hedvig.android.design.system.hedvig.HedvigErrorSection import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgressDebounced import com.hedvig.android.design.system.hedvig.HedvigPreview @@ -42,20 +37,12 @@ import com.hedvig.android.design.system.hedvig.HedvigTheme import com.hedvig.android.design.system.hedvig.Surface import com.hedvig.android.design.system.hedvig.icon.HedvigIcons import com.hedvig.android.design.system.hedvig.icon.Settings -import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore -import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator -import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId import com.hedvig.android.feature.onboarding.ui.OnboardingProgress import com.hedvig.android.feature.onboarding.ui.OnboardingProgressBarAnimation import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons import com.hedvig.android.feature.onboarding.ui.OnboardingStepHeader import com.hedvig.android.feature.onboarding.ui.OnboardingStepScaffold -import com.hedvig.android.feature.onboarding.ui.progressFor -import com.hedvig.android.molecule.public.MoleculePresenter -import com.hedvig.android.molecule.public.MoleculePresenterScope -import com.hedvig.android.molecule.public.MoleculeViewModel import com.hedvig.android.theme.Theme -import dev.zacsweers.metro.Inject import hedvig.resources.ONBOARDING_CHANGE_SETTINGS_LATER_LABEL import hedvig.resources.ONBOARDING_THEME_DARK_SUBTITLE import hedvig.resources.ONBOARDING_THEME_LIGHT_SUBTITLE @@ -67,7 +54,6 @@ import hedvig.resources.SETTINGS_THEME_DIALOG_TITLE import hedvig.resources.SETTINGS_THEME_LIGHT import hedvig.resources.SETTINGS_THEME_SYSTEM_DEFAULT import hedvig.resources.general_continue_button -import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource @Composable @@ -92,7 +78,7 @@ private fun OnboardingThemeScreen( onRetry: () -> Unit, onSelectTheme: (Theme) -> Unit, onContinueClick: () -> Unit, - progressAnimation: OnboardingProgressBarAnimation? = null, + progressAnimation: OnboardingProgressBarAnimation, ) { OnboardingStepScaffold( progress = (uiState as? OnboardingThemeUiState.Content)?.progress, @@ -259,18 +245,31 @@ private val DarkThemeSwatchColor = Color(0xFF121212) @HedvigPreview @Composable -private fun PreviewOnboardingThemeScreen() { +private fun PreviewOnboardingThemeScreen( + @PreviewParameter(OnboardingThemeUiStateProvider::class) uiState: OnboardingThemeUiState, +) { HedvigTheme { - OnboardingThemeScreen( - uiState = OnboardingThemeUiState.Content( - progress = OnboardingProgress(totalSteps = 5, currentIndex = 2), - selectedTheme = Theme.SYSTEM_DEFAULT, - ), - navigateUp = {}, - onCloseClick = {}, - onRetry = {}, - onSelectTheme = {}, - onContinueClick = {}, - ) + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + OnboardingThemeScreen( + uiState = uiState, + progressAnimation = remember { OnboardingProgressBarAnimation() }, + navigateUp = {}, + onCloseClick = {}, + onRetry = {}, + onSelectTheme = {}, + onContinueClick = {}, + ) + } } } + +private class OnboardingThemeUiStateProvider : CollectionPreviewParameterProvider( + listOf( + OnboardingThemeUiState.Loading, + OnboardingThemeUiState.Error, + OnboardingThemeUiState.Content( + progress = OnboardingProgress(totalSteps = 5, currentIndex = 2), + selectedTheme = Theme.SYSTEM_DEFAULT, + ), + ), +) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeViewModel.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeViewModel.kt index 6773fef0ce..2c71d2f83e 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeViewModel.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeViewModel.kt @@ -1,18 +1,5 @@ package com.hedvig.android.feature.onboarding.ui.theme -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.selection.selectable import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -21,54 +8,21 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.hedvig.android.core.common.di.ActivityRetainedScope import com.hedvig.android.core.common.di.HedvigViewModel import com.hedvig.android.data.settings.datastore.SettingsDataStore -import com.hedvig.android.design.system.hedvig.HedvigErrorSection -import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgressDebounced -import com.hedvig.android.design.system.hedvig.HedvigPreview -import com.hedvig.android.design.system.hedvig.HedvigText -import com.hedvig.android.design.system.hedvig.HedvigTheme -import com.hedvig.android.design.system.hedvig.Surface -import com.hedvig.android.design.system.hedvig.icon.HedvigIcons -import com.hedvig.android.design.system.hedvig.icon.Settings import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId import com.hedvig.android.feature.onboarding.ui.OnboardingProgress import com.hedvig.android.feature.onboarding.ui.OnboardingProgressBarAnimation -import com.hedvig.android.feature.onboarding.ui.OnboardingStepButtons -import com.hedvig.android.feature.onboarding.ui.OnboardingStepHeader -import com.hedvig.android.feature.onboarding.ui.OnboardingStepScaffold import com.hedvig.android.feature.onboarding.ui.progressFor import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel import com.hedvig.android.theme.Theme import dev.zacsweers.metro.Inject -import hedvig.resources.ONBOARDING_CHANGE_SETTINGS_LATER_LABEL -import hedvig.resources.ONBOARDING_THEME_DARK_SUBTITLE -import hedvig.resources.ONBOARDING_THEME_LIGHT_SUBTITLE -import hedvig.resources.ONBOARDING_THEME_SUBTITLE -import hedvig.resources.ONBOARDING_THEME_SYSTEM_SUBTITLE -import hedvig.resources.Res -import hedvig.resources.SETTINGS_THEME_DARK -import hedvig.resources.SETTINGS_THEME_DIALOG_TITLE -import hedvig.resources.SETTINGS_THEME_LIGHT -import hedvig.resources.SETTINGS_THEME_SYSTEM_DEFAULT -import hedvig.resources.general_continue_button import kotlinx.coroutines.launch -import org.jetbrains.compose.resources.stringResource @Inject @HedvigViewModel(ActivityRetainedScope::class) From 99091920364f2b4e9b9c0e209ff2456a0f9cf88e Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Thu, 6 Aug 2026 10:38:25 +0200 Subject: [PATCH 103/119] Fix preview --- .../hedvig/android/design/system/hedvig/ThreeDotsLoading.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/ThreeDotsLoading.kt b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/ThreeDotsLoading.kt index 1942a34cb4..f7259513a8 100644 --- a/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/ThreeDotsLoading.kt +++ b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/ThreeDotsLoading.kt @@ -74,8 +74,7 @@ private const val dotBreathDurationMilliseconds = 600 private const val dotStaggerMilliseconds = 200 private const val numberOfDots = 3 -// @HedvigPreview -@Preview +@HedvigPreview @Composable private fun PreviewThreeDotsLoading() { HedvigTheme { From 4130cbcf2deff66941cd8ef4b55990dbdacd3574 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Thu, 6 Aug 2026 10:39:02 +0200 Subject: [PATCH 104/119] Pop the chip-id flow back to its caller instead of going home The chip-id completion used the presence of ChipIdKey to decide between popping back and going home. When a single contract is missing an ID the select screen auto-advances and pops ChipIdKey, so completion fell through to goHome and dropped the user on the Home tab. This broke the onboarding pet-ID step (return to the step) and the Insurances contract-detail entry (return to the contract) for the common single-pet case. Pop the flow's own entries so whatever launched it shows through, and keep goHome only when the flow is the stack root (a lone deep link with no in-app caller). Cover the branches with PopChipIdFlowOnSuccessTest. --- app/feature/feature-chip-id/build.gradle.kts | 2 + .../chip/id/navigation/ChipIdEntries.kt | 23 +++++-- .../navigation/PopChipIdFlowOnSuccessTest.kt | 68 +++++++++++++++++++ 3 files changed, 86 insertions(+), 7 deletions(-) create mode 100644 app/feature/feature-chip-id/src/test/kotlin/com/hedvig/android/feature/chip/id/navigation/PopChipIdFlowOnSuccessTest.kt diff --git a/app/feature/feature-chip-id/build.gradle.kts b/app/feature/feature-chip-id/build.gradle.kts index c31b960eb2..e1804e5d3a 100644 --- a/app/feature/feature-chip-id/build.gradle.kts +++ b/app/feature/feature-chip-id/build.gradle.kts @@ -31,4 +31,6 @@ dependencies { implementation(projects.navigationCore) implementation(projects.navigationCommon) implementation(projects.navigationCompose) + testImplementation(libs.assertK) + testImplementation(libs.junit) } 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 701a26f7b1..309d3d74ba 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 @@ -42,13 +42,22 @@ fun EntryProviderScope.chipIdEntries( viewModel = viewModel, globalSnackBarState = globalSnackBarState, navigateUp = backstack::navigateUp, - popFlowOnSuccess = { - if (backstack.findLastOrNull() != null) { - backstack.popUpTo(inclusive = true) - } else { - goHome() - } - }, + popFlowOnSuccess = { backstack.popChipIdFlowOnSuccess(goHome) }, ) } } + +/** + * Pops the chip-id flow's own entries so the caller that launched it (an onboarding step, a contract + * detail, a tab root) shows through. For a single missing contract the select screen auto-advances + * and pops [ChipIdKey], leaving only [AddChipIdKey] on top of the caller, so that case pops one entry. + * [goHome] is used only when the flow is the base of the stack, i.e. a lone deep link with no in-app + * caller to return to. + */ +internal fun Backstack.popChipIdFlowOnSuccess(goHome: () -> Unit) { + when { + findLastOrNull() != null -> popUpTo(inclusive = true) + entries.size > 1 -> popBackstack() + else -> goHome() + } +} diff --git a/app/feature/feature-chip-id/src/test/kotlin/com/hedvig/android/feature/chip/id/navigation/PopChipIdFlowOnSuccessTest.kt b/app/feature/feature-chip-id/src/test/kotlin/com/hedvig/android/feature/chip/id/navigation/PopChipIdFlowOnSuccessTest.kt new file mode 100644 index 0000000000..b685e6d368 --- /dev/null +++ b/app/feature/feature-chip-id/src/test/kotlin/com/hedvig/android/feature/chip/id/navigation/PopChipIdFlowOnSuccessTest.kt @@ -0,0 +1,68 @@ +package com.hedvig.android.feature.chip.id.navigation + +import assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isTrue +import com.hedvig.android.navigation.common.HedvigNavKey +import com.hedvig.android.navigation.compose.Backstack +import kotlinx.serialization.Serializable +import org.junit.Test + +internal class PopChipIdFlowOnSuccessTest { + private class TestBackstack(vararg initial: HedvigNavKey) : Backstack { + override val entries: MutableList = initial.toMutableList() + } + + @Test + fun `single contract with a caller underneath pops back to the caller`() { + // The select screen auto-advanced and popped ChipIdKey, so only AddChipIdKey remains. + val backstack = TestBackstack(CallerKey, AddChipIdKey("c1")) + var wentHome = false + + backstack.popChipIdFlowOnSuccess { wentHome = true } + + assertThat(backstack.entries).containsExactly(CallerKey) + assertThat(wentHome).isFalse() + } + + @Test + fun `multiple contracts pop the whole flow back to the caller`() { + val backstack = TestBackstack(CallerKey, ChipIdKey(), AddChipIdKey("c1")) + var wentHome = false + + backstack.popChipIdFlowOnSuccess { wentHome = true } + + assertThat(backstack.entries).containsExactly(CallerKey) + assertThat(wentHome).isFalse() + } + + @Test + fun `lone deep link with no caller falls back to going home`() { + val backstack = TestBackstack(AddChipIdKey("c1")) + var wentHome = false + + backstack.popChipIdFlowOnSuccess { wentHome = true } + + assertThat(wentHome).isTrue() + } + + @Test + fun `multiple callers are preserved, only the flow is popped`() { + val backstack = TestBackstack(CallerKey, OtherCallerKey, AddChipIdKey("c1")) + var wentHome = false + + backstack.popChipIdFlowOnSuccess { wentHome = true } + + assertThat(backstack.entries).containsExactly(CallerKey, OtherCallerKey) + assertThat(backstack.entries.last()).isEqualTo(OtherCallerKey) + assertThat(wentHome).isFalse() + } +} + +@Serializable +private data object CallerKey : HedvigNavKey + +@Serializable +private data object OtherCallerKey : HedvigNavKey From d4a230e75fc2d3e5ead97464b857cc8cad7805c2 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Thu, 6 Aug 2026 13:36:02 +0200 Subject: [PATCH 105/119] Only offer the payment skip after an attempt to connect Follow the Figma connect-payment design, which shows just [Connect payment] until a method is connected. Removing the skip outright would strand a member who opens the connect flow and returns without connecting, so reveal the [Do this later] secondary only once they have entered the connect flow at least once and come back still not connected. Connection is still derived from live payinStatus, never from the button press. Also document why terminate-insurance's onDone can pop plainly: the flow is collapsed to a single TerminationSuccessKey on success, so one pop returns to the caller. --- .../payment/OnboardingPaymentDestination.kt | 52 +++++++++++++------ .../payment/OnboardingPaymentPresenterTest.kt | 24 +++++++++ .../navigation/TerminateInsuranceEntries.kt | 3 ++ 3 files changed, 64 insertions(+), 15 deletions(-) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt index c8e68ccf40..8109f40f97 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt @@ -71,6 +71,7 @@ internal class OnboardingPaymentPresenter( ): OnboardingPaymentUiState { var currentState by remember { mutableStateOf(lastState) } var loadIteration by remember { mutableIntStateOf(0) } + var hasAttemptedToConnect by remember { mutableStateOf(false) } LaunchedEffect(loadIteration) { if (currentState is OnboardingPaymentUiState.Content) return@LaunchedEffect @@ -88,26 +89,40 @@ internal class OnboardingPaymentPresenter( CollectEvents { event -> when (event) { - OnboardingPaymentEvent.Retry -> loadIteration++ + OnboardingPaymentEvent.Retry -> { + loadIteration++ + } - OnboardingPaymentEvent.Close -> launch { navigator.exitOnboarding() } + OnboardingPaymentEvent.Close -> { + launch { navigator.exitOnboarding() } + } - OnboardingPaymentEvent.Refresh -> launch { - sessionStore.refreshData().onRight { refreshed -> - currentState = OnboardingPaymentUiState.Content( - progress = refreshed.progressFor(OnboardingStepId.ConnectPayment), - payinStatus = refreshed.data.payinStatus, - ) + OnboardingPaymentEvent.Refresh -> { + launch { + sessionStore.refreshData().onRight { refreshed -> + currentState = OnboardingPaymentUiState.Content( + progress = refreshed.progressFor(OnboardingStepId.ConnectPayment), + payinStatus = refreshed.data.payinStatus, + ) + } } } - OnboardingPaymentEvent.ConnectPayment -> navigator.openConnectPayment() + OnboardingPaymentEvent.ConnectPayment -> { + hasAttemptedToConnect = true + navigator.openConnectPayment() + } - OnboardingPaymentEvent.Continue -> launch { navigator.continueFrom(OnboardingStepId.ConnectPayment) } + OnboardingPaymentEvent.Continue -> { + launch { navigator.continueFrom(OnboardingStepId.ConnectPayment) } + } } } - return currentState + return when (val state = currentState) { + is OnboardingPaymentUiState.Content -> state.copy(hasAttemptedToConnect = hasAttemptedToConnect) + else -> state + } } } @@ -119,6 +134,9 @@ internal sealed interface OnboardingPaymentUiState { data class Content( val progress: OnboardingProgress, val payinStatus: OnboardingPayinStatus, + // The skip is offered only once the member has entered the connect flow at least once, so a + // member who tries and does not finish is never stuck on this step. + val hasAttemptedToConnect: Boolean = false, ) : OnboardingPaymentUiState } @@ -193,6 +211,7 @@ private fun OnboardingPaymentScreen( // is derived straight from the live status, so moving forward and back never claims a // connection the backend does not report. val isConnected = content.payinStatus != OnboardingPayinStatus.NeedsSetup + val canSkip = !isConnected && content.hasAttemptedToConnect Spacer(Modifier.height(16.dp)) // Keep the subtitle in every state so the header height (and the graphic below it) never // shifts vertically as the status changes. @@ -226,10 +245,8 @@ private fun OnboardingPaymentScreen( stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_TITLE) }, onPrimaryClick = if (isConnected) onContinue else onConnectPayment, - // Connecting a payment method is always optional, so the not-yet-connected state offers a - // skip. Once connected the primary button already continues, so no secondary is needed. - secondaryText = if (isConnected) null else stringResource(Res.string.ONBOARDING_DO_THIS_LATER_BUTTON), - onSecondaryClick = if (isConnected) null else onContinue, + secondaryText = if (canSkip) stringResource(Res.string.ONBOARDING_DO_THIS_LATER_BUTTON) else null, + onSecondaryClick = if (canSkip) onContinue else null, ) } } @@ -264,6 +281,11 @@ private class OnboardingPaymentUiStateProvider : CollectionPreviewParameterProvi progress = OnboardingProgress(totalSteps = 5, currentIndex = 3), payinStatus = OnboardingPayinStatus.NeedsSetup, ), + OnboardingPaymentUiState.Content( + progress = OnboardingProgress(totalSteps = 5, currentIndex = 3), + payinStatus = OnboardingPayinStatus.NeedsSetup, + hasAttemptedToConnect = true, + ), OnboardingPaymentUiState.Content( progress = OnboardingProgress(totalSteps = 5, currentIndex = 3), payinStatus = OnboardingPayinStatus.Pending, diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentPresenterTest.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentPresenterTest.kt index 04357a2441..e15e109852 100644 --- a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentPresenterTest.kt +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentPresenterTest.kt @@ -4,7 +4,9 @@ import androidx.compose.runtime.mutableStateListOf import arrow.core.right import assertk.assertThat import assertk.assertions.isEqualTo +import assertk.assertions.isFalse import assertk.assertions.isInstanceOf +import assertk.assertions.isTrue import com.hedvig.android.feature.connect.payment.trustly.ui.TrustlyKey import com.hedvig.android.feature.onboarding.FakeOnboardingMemberIdProvider import com.hedvig.android.feature.onboarding.FakeOnboardingRepository @@ -67,11 +69,33 @@ internal class OnboardingPaymentPresenterTest { repository.onboardingDataResponses.add(testOnboardingData().right()) awaitItem() sendEvent(OnboardingPaymentEvent.ConnectPayment) + awaitItem() runCurrent() assertThat(backstack.entries.last()).isEqualTo(TrustlyKey) } } + @Test + fun `skip is offered only after entering the connect flow while still not connected`() = runTest { + val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.ConnectPayment)) } + val repository = FakeOnboardingRepository() + val sessionStore = OnboardingSessionStore(repository, FakeOnboardingMemberIdProvider()) + val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) + val presenter = OnboardingPaymentPresenter(sessionStore, navigator) + + presenter.test(OnboardingPaymentUiState.Loading) { + skipItems(1) + repository.onboardingDataResponses.add(testOnboardingData(payinStatus = OnboardingPayinStatus.NeedsSetup).right()) + val initial = awaitItem() as OnboardingPaymentUiState.Content + assertThat(initial.hasAttemptedToConnect).isFalse() + + sendEvent(OnboardingPaymentEvent.ConnectPayment) + val afterAttempt = awaitItem() as OnboardingPaymentUiState.Content + assertThat(afterAttempt.hasAttemptedToConnect).isTrue() + assertThat(afterAttempt.payinStatus).isEqualTo(OnboardingPayinStatus.NeedsSetup) + } + } + @Test fun `refresh after returning with an active method flips to connected`() = runTest { val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.ConnectPayment)) } 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 b44eda01b0..4abe1f0216 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 @@ -64,6 +64,9 @@ fun EntryProviderScope.terminateInsuranceEntries( TerminationSuccessDestination( terminationDate = key.terminationDate, onDone = { + // The flow is collapsed to this single TerminationSuccessKey on success, so one pop returns + // to whatever launched it. Keep that collapse intact if steps are ever added after success, + // otherwise this plain pop would land on an intermediate screen instead of the caller. // 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) { From 38f31768a3939a725efcb510045145b91567f8d6 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 11 Aug 2026 13:29:52 +0200 Subject: [PATCH 106/119] QA: check off gate/nav sections and automated phone/theme items --- .../2026-07-24-onboarding-qa-checklist.md | 73 +++++++++++-------- 1 file changed, 42 insertions(+), 31 deletions(-) diff --git a/docs/superpowers/2026-07-24-onboarding-qa-checklist.md b/docs/superpowers/2026-07-24-onboarding-qa-checklist.md index 42bdba213d..de45050c2a 100644 --- a/docs/superpowers/2026-07-24-onboarding-qa-checklist.md +++ b/docs/superpowers/2026-07-24-onboarding-qa-checklist.md @@ -4,8 +4,8 @@ Branch: `feat/onboarding`. Check off as you go; add notes inline under any faili ## Setup -- [ ] Build and install: `./gradlew :app:installDebug` (app id `com.hedvig.dev.app`, staging backend) -- [ ] Unleash: `disable_onboarding` toggle created, currently OFF +- [X] Build and install: `./gradlew :app:installDebug` (app id `com.hedvig.dev.app`, staging backend) +- [X] Unleash: `disable_onboarding` toggle created, currently OFF - [ ] Member A ("maximal"): missing co-insured, pet missing ID, no payin method, referral code, cross-sell eligible (expect 9 progress segments) - [ ] Member B ("complete"): payment connected, nothing missing (expect 4 segments: welcome, consent, phone, theme) - [ ] Firebase DebugView armed: `adb shell setprop debug.firebase.analytics.app com.hedvig.dev.app` @@ -14,29 +14,31 @@ Reset between runs: Profile > About app > **Reset onboarding** (non-production r ## A. Gate and show-once -- [ ] A1. Fresh login, land on Home: onboarding appears (welcome, no back arrow, progress at segment 1) -- [ ] A2. Segment count matches the member's applicable steps (compare A vs B members) -- [ ] A3. X on any step: back on Home; kill + relaunch: never returns -- [ ] A4. Complete via "Continue to app": same, never returns -- [ ] A5. System back on welcome: predictive-back preview works natively, flow dismisses, AND is marked seen (background + foreground: does not come back) -- [ ] A6. Kill switch: `disable_onboarding` ON + reset onboarding + background/foreground: nothing appears; OFF again + background/foreground: appears -- [ ] A7. Member switch: logout, login as unseen member B: appears with B's data everywhere (phone, incentive, contracts; no member A leakage) -- [ ] A8. Offline first reach of Home: no flow, no crash; back online, kill + relaunch: appears (failure never marks seen) -- [ ] A9. Deep link (`adb shell am start -a android.intent.action.VIEW -d ""`) with unseen member: deep-linked screen uninterrupted; flow appears once on an idle Home -- [ ] A10. Demo mode: no onboarding +- [X] A1. Fresh login, land on Home: onboarding appears (welcome, no back arrow, progress at segment 1) +- [X] A2. Segment count matches the member's applicable steps (compare A vs B members) +- [X] A3. X on any step: back on Home; kill + relaunch: never returns +- [X] A4. Complete via "Continue to app": same, never returns +- [X] A5. System back on welcome: predictive-back preview works natively, flow dismisses, AND is marked seen (background + foreground: does not come back) +- [X] A6. Kill switch: `disable_onboarding` ON + reset onboarding + background/foreground: nothing appears; OFF again + background/foreground: appears +- [X] A7. Member switch: logout, login as unseen member B: appears with B's data everywhere (phone, incentive, contracts; no member A leakage) +- [X] A8. Offline first reach of Home: no flow, no crash; back online, kill + relaunch: appears (failure never marks seen) +- [X] A9. Deep link (`adb shell am start -a android.intent.action.VIEW -d ""`) with unseen member: deep-linked screen uninterrupted; flow appears once on an idle Home +- [X] A10. Demo mode: no onboarding ## B. Navigation mechanics -- [ ] B1. Back arrow pops exactly one step; system back matches on every step, predictive preview included -- [ ] B2. Process death mid-flow (background, `adb shell am kill com.hedvig.dev.app`, reopen from recents): step restores, loading, refetch, progress bar returns -- [ ] B3. After a process-death restore, system back on welcome STILL marks seen (the dismissal observer re-arms on resume) -- [ ] B4. Rotate every screen +- [X] B1. Back arrow pops exactly one step; system back matches on every step, predictive preview included +- [X] B2. Process death mid-flow (background, `adb shell am kill com.hedvig.dev.app`, reopen from recents): step restores, loading, refetch, progress bar returns +- [X] B3. After a process-death restore, system back on welcome STILL marks seen (the dismissal observer re-arms on resume) +- [X] B4. Rotate every screen + - Sampled connect-payment (graphic) and invite (card list) portrait↔landscape: shared scaffold reflows, no crash. Not every single screen rotated individually. ## C. Per step ### Consent -- [ ] Underlined "Privacy policy" + northeast arrow opens the language-correct URL -- [ ] Allow and Deny are two identical grey pills, Allow on top +- [X] Underlined "Privacy policy" + northeast arrow opens the language-correct URL + - Opened hedvig.com/se-en/... "Hedvig Privacy Policy" in the browser. +- [X] Allow and Deny are two identical grey pills, Allow on top - [ ] Allow advances; Settings > Product analytics shows "On" - [ ] Deny advances; Settings shows "Off" - [ ] Decide nothing (dismiss flow first): Settings shows "Not set" @@ -45,36 +47,43 @@ Reset between runs: Profile > About app > **Reset onboarding** (non-production r ### Phone - [ ] Field bottom-anchored above Save; no cursor lag while typing -- [ ] Pre-filled with current number +- [X] Pre-filled with current number - [ ] Save advances; profile contact info shows the new number - [ ] Save offline: inline error, typed digits preserved, stays on step -- [ ] Blank number disables Save -- [ ] "Do this later" (grey pill) advances without any mutation +- [X] Blank number disables Save + - Cleared the field via adb; tapping Save did not advance (design-system button drops its click when disabled even though the a11y node stays enabled). +- [X] "Do this later" (grey pill) advances without any mutation + - Skipped from a blank field to Theme; no save call, account number unchanged. - [ ] Back to the step after a successful save: Save enabled again ### Theme -- [ ] Design-system radio option cards (title + subtitle) +- [X] Design-system radio option cards (title + subtitle) - [ ] Selection applies instantly app-wide, survives relaunch, agrees with the Settings screen row + - Instant apply + relaunch persistence confirmed via automation (screen luminance 231→42 on selecting Dark; still 36 after a force-stop restart). Settings-row agreement still not checked (the Profile screen's settings entry wasn't reachable via automation). ### Co-insured / Pet ID - [ ] Centered grey cards with contract pillow images, dark pill "Add" -- [ ] Add opens the existing flow; completing it returns to the step with a checkmark on that card (row does not vanish) +- [X] Add opens the existing flow; completing it returns to the step with a checkmark on that card (row does not vanish) + - Pet-ID verified; co-insured step not yet driven on-device. - [ ] Aborting the external flow returns with the row unchanged - [ ] Fine-print hint above the buttons ### Invite a friend -- [ ] Card with Hampus / Li / Elin, green dots, real "-10 SEK"-style amounts; no referral code shown -- [ ] "Invite a friend" (grey, ABOVE Continue) opens the Forever screen standalone, no bottom nav -- [ ] System back from Forever returns to the invite step with the flow intact +- [X] Card with Hampus / Li / Elin, green dots, real "-10 SEK"-style amounts; no referral code shown +- [X] "Invite a friend" (grey, ABOVE Continue) opens the Forever screen standalone, no bottom nav +- [X] System back from Forever returns to the invite step with the flow intact ### Connect payment -- [ ] Fine-print caption; single "Connect payment" pill (no do-later; only X) +- [X] Fine-print caption; first view is a single "Connect payment" pill (only X, no do-later). A "Do this later" pill appears only after entering the connect flow and returning still unconnected. + - First-view (only Connect payment) verified on-device; the appears-after-attempt reveal is covered by OnboardingPaymentPresenterTest (a Trustly abort on staging leaves a PENDING method, which correctly shows connected instead). - [ ] Trustly completion (staging test bank): connected variant (text + Continue, no checkmark icon) -- [ ] Aborted Trustly that left a PENDING method showing "connected" is CORRECT behavior (deliberate) +- [X] Aborted Trustly that left a PENDING method showing "connected" is CORRECT behavior (deliberate) ### Bundle discount -- [ ] Cross-sell pillow images load (fallback painter offline) -- [ ] Grey "See price" pills open the storeUrl in the browser +- [X] Cross-sell pillow images load (fallback painter offline) + - Images loaded online (Accident/Home/Pet/Vacation Home/Payment Protection). Offline fallback painter not tested. +- [X] Grey "See price" pills open the storeUrl in the browser + - Opened dev.hedvigit.com/api/... in Chrome. - [ ] "Continue to app" ends the flow ## D. Regression edges @@ -86,4 +95,6 @@ Reset between runs: Profile > About app > **Reset onboarding** (non-production r ## Notes / found issues -- +- Connect payment: a Trustly abort at the very first Trustly screen still registers a PENDING method on staging, so the step returns showing "connected" (Continue). This is the deliberate behavior, but it means the "Do this later" reveal is hard to reproduce on-device from a normal abort. +- Drive was done on a member whose path was Welcome → Consent → Phone → Theme → Invite → Connect payment → Bundle (7 segments): no co-insured or pet-ID step, so those steps were not reachable in this session. Co-insured Add→return still needs an on-device pass with a member missing co-insured info. +- Copy: bundle step subtitle says "15% bundle discount" on-device vs "10%" in the Figma frame. Confirm which is intended (looks like a config/copy value, not a bug). From eef3c03a7486175fd291927531c6dbacd2714baf Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 11 Aug 2026 14:15:48 +0200 Subject: [PATCH 107/119] QA: pet-ID pass on a missing-pet member; log the completed-row-vanish bug --- .../2026-07-24-onboarding-qa-checklist.md | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/2026-07-24-onboarding-qa-checklist.md b/docs/superpowers/2026-07-24-onboarding-qa-checklist.md index de45050c2a..1b04564185 100644 --- a/docs/superpowers/2026-07-24-onboarding-qa-checklist.md +++ b/docs/superpowers/2026-07-24-onboarding-qa-checklist.md @@ -62,11 +62,14 @@ Reset between runs: Profile > About app > **Reset onboarding** (non-production r - Instant apply + relaunch persistence confirmed via automation (screen luminance 231→42 on selecting Dark; still 36 after a force-stop restart). Settings-row agreement still not checked (the Profile screen's settings entry wasn't reachable via automation). ### Co-insured / Pet ID -- [ ] Centered grey cards with contract pillow images, dark pill "Add" -- [X] Add opens the existing flow; completing it returns to the step with a checkmark on that card (row does not vanish) - - Pet-ID verified; co-insured step not yet driven on-device. -- [ ] Aborting the external flow returns with the row unchanged -- [ ] Fine-print hint above the buttons +- [X] Centered grey cards with contract pillow images, dark pill "Add" + - Pet-ID: "Dog Insurance Standard / Jackk" card with pillow + dark "Add" pill. +- [ ] Add opens the existing flow; completing it returns to the step with a checkmark on that card (row does not vanish) + - Add opens the chip-id flow ✓, and completing it saves the ID (Add gone) ✓, BUT the completed row VANISHES instead of showing the checkmark. See found issue below — regression from the WhileSubscribed(5s) presenter timeout defeating the pin. +- [X] Aborting the external flow returns with the row unchanged + - Back from the chip-id flow returns to the pet-ID step with the "Add" row intact (chip-id nav fix works). +- [X] Fine-print hint above the buttons + - "You can add this information later" above Continue / Do this later. ### Invite a friend - [X] Card with Hampus / Li / Elin, green dots, real "-10 SEK"-style amounts; no referral code shown @@ -84,7 +87,8 @@ Reset between runs: Profile > About app > **Reset onboarding** (non-production r - Images loaded online (Accident/Home/Pet/Vacation Home/Payment Protection). Offline fallback painter not tested. - [X] Grey "See price" pills open the storeUrl in the browser - Opened dev.hedvigit.com/api/... in Chrome. -- [ ] "Continue to app" ends the flow +- [X] "Continue to app" ends the flow + - Lands on Home, onboarding gone (does not return). ## D. Regression edges @@ -98,3 +102,4 @@ Reset between runs: Profile > About app > **Reset onboarding** (non-production r - Connect payment: a Trustly abort at the very first Trustly screen still registers a PENDING method on staging, so the step returns showing "connected" (Continue). This is the deliberate behavior, but it means the "Do this later" reveal is hard to reproduce on-device from a normal abort. - Drive was done on a member whose path was Welcome → Consent → Phone → Theme → Invite → Connect payment → Bundle (7 segments): no co-insured or pet-ID step, so those steps were not reachable in this session. Co-insured Add→return still needs an on-device pass with a member missing co-insured info. - Copy: bundle step subtitle says "15% bundle discount" on-device vs "10%" in the Figma frame. Confirm which is intended (looks like a config/copy value, not a bug). +- **BUG — completed pet-ID (and co-insured) row vanishes instead of staying with a checkmark.** Repro: single missing pet, tap Add, enter an ID, Save. On return the row disappears; the step shows an empty list. Root cause: the "pinned contract ids" that keep completed rows visible live in the presenter's `remember`, but `MoleculeViewModel` shares with `SharingStarted.WhileSubscribed(5.seconds)`, so a >5s trip into the chip-id/Trustly flow disposes the presenter and resets the pin to the now-empty missing-list. A quick abort (<5s) keeps the row; a normal completion loses it. Same mechanism also resets the connect-payment `hasAttemptedToConnect` (skip-after-attempt reveal) and the invite one-shot animation flag. Fix: hold the pin in a longer-lived scope (e.g. OnboardingSessionStore, ActivityRetainedScope) rather than presenter `remember`. From bf838153c2459e7d0d19a2090e1c6eaa9a84e613 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 11 Aug 2026 14:29:02 +0200 Subject: [PATCH 108/119] Pin onboarding step contracts in the retained session, not the presenter The pet-ID and co-insured steps kept completed rows visible by remembering which contracts the step owns in the presenter. But MoleculeViewModel shares with WhileSubscribed(5s), so a >5s trip into the chip-id/edit flow disposes the presenter and re-pins from the now-empty missing-list, making the completed row vanish. Move the pin into OnboardingSession (held by the ActivityRetainedScope session store, captured at first load and preserved across refreshData) so completion keeps the row with its checkmark. --- .../onboarding/data/OnboardingSessionStore.kt | 17 +++++++++++++++-- .../coinsured/OnboardingCoInsuredViewModel.kt | 10 +--------- .../ui/petid/OnboardingPetIdViewModel.kt | 8 +------- 3 files changed, 17 insertions(+), 18 deletions(-) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingSessionStore.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingSessionStore.kt index 1bb7cbfe8d..0ab669b27b 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingSessionStore.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingSessionStore.kt @@ -7,6 +7,7 @@ import com.hedvig.android.auth.MemberIdService import com.hedvig.android.core.common.ErrorMessage import com.hedvig.android.core.common.di.ActivityRetainedScope import com.hedvig.android.core.common.di.AppScope +import com.hedvig.android.data.coinsured.CoInsuredFlowType import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject @@ -61,8 +62,16 @@ internal class OnboardingSessionStore( } cachedSession = null onboardingRepository.getOnboardingData().map { data -> - OnboardingSession(memberId = currentMemberId, data = data, path = buildOnboardingPath(data)) - .also { cachedSession = it } + OnboardingSession( + memberId = currentMemberId, + data = data, + path = buildOnboardingPath(data), + // Capture which contracts each step owns from the first data, so completed rows stay pinned + // as "done" instead of vanishing once a later refresh no longer lists them as missing. + pinnedPetIdContractIds = data.contractsWithMissingPetId.map { it.id }, + pinnedCoInsuredContracts = data.contractsMissingInsuredOrOwnerInfo + .mapNotNull { contract -> contract.coInsuredFlowType?.let { contract.id to it } }, + ).also { cachedSession = it } } } @@ -88,4 +97,8 @@ internal data class OnboardingSession( val memberId: String, val data: OnboardingData, val path: List, + /** Contract ids the pet-ID step owns, captured at first load and kept stable across refreshes. */ + val pinnedPetIdContractIds: List, + /** Contract ids (with their original flow type) the co-insured step owns, captured at first load. */ + val pinnedCoInsuredContracts: List>, ) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredViewModel.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredViewModel.kt index f93d7b71d7..9a4e338cac 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredViewModel.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredViewModel.kt @@ -45,16 +45,8 @@ internal class OnboardingCoInsuredPresenter( var currentState by remember { mutableStateOf(lastState) } var loadIteration by remember { mutableIntStateOf(0) } - // Pin which contracts belong to this step (and each one's flow type) the first time data is - // available, so completed rows render as done instead of disappearing and the header title - // stays stable even after a contract's missing info is filled in. - var pinnedContracts by remember { mutableStateOf>?>(null) } - fun contentFrom(session: OnboardingSession): OnboardingCoInsuredUiState.Content { - val pinned = pinnedContracts - ?: session.data.contractsMissingInsuredOrOwnerInfo - .mapNotNull { contract -> contract.coInsuredFlowType?.let { contract.id to it } } - .also { pinnedContracts = it } + val pinned = session.pinnedCoInsuredContracts return OnboardingCoInsuredUiState.Content( progress = session.progressFor(OnboardingStepId.CoInsured), rows = pinned.mapNotNull { (id, flowType) -> diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdViewModel.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdViewModel.kt index 326afc4dbd..f6c34b888a 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdViewModel.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdViewModel.kt @@ -44,16 +44,10 @@ internal class OnboardingPetIdPresenter( var currentState by remember { mutableStateOf(lastState) } var loadIteration by remember { mutableIntStateOf(0) } - // Pin which contracts belong to this step the first time data is available, so completed - // rows render as done instead of disappearing. - var pinnedContractIds by remember { mutableStateOf?>(null) } - fun contentFrom(session: OnboardingSession): OnboardingPetIdUiState.Content { - val ids = pinnedContractIds - ?: session.data.contractsWithMissingPetId.map { it.id }.also { pinnedContractIds = it } return OnboardingPetIdUiState.Content( progress = session.progressFor(OnboardingStepId.PetIds), - rows = ids.mapNotNull { id -> + rows = session.pinnedPetIdContractIds.mapNotNull { id -> val contract = session.data.contracts.firstOrNull { it.id == id } ?: return@mapNotNull null PetIdRow( contractId = contract.id, From ebf990107c5e1e3e0e3fb9de90052eb917fe87d0 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 11 Aug 2026 14:29:02 +0200 Subject: [PATCH 109/119] Log analytics consent + effective collection state for on-device QA Emit a DEBUG line (tag AnalyticsConsentDebug) whenever consent is applied, so the consent gate can be verified with adb logcat instead of Firebase DebugView. --- .../firebase/ConsentAwareEventTrackingClient.kt | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/app/tracking/tracking-firebase/src/main/kotlin/com/hedvig/android/tracking/firebase/ConsentAwareEventTrackingClient.kt b/app/tracking/tracking-firebase/src/main/kotlin/com/hedvig/android/tracking/firebase/ConsentAwareEventTrackingClient.kt index 02b4069d76..27dd426e7f 100644 --- a/app/tracking/tracking-firebase/src/main/kotlin/com/hedvig/android/tracking/firebase/ConsentAwareEventTrackingClient.kt +++ b/app/tracking/tracking-firebase/src/main/kotlin/com/hedvig/android/tracking/firebase/ConsentAwareEventTrackingClient.kt @@ -6,6 +6,7 @@ import com.hedvig.android.core.common.di.IoDispatcher import com.hedvig.android.core.tracking.EventTrackingClient import com.hedvig.android.data.settings.datastore.AnalyticsConsent import com.hedvig.android.data.settings.datastore.SettingsDataStore +import com.hedvig.android.logger.LogPriority import com.hedvig.android.logger.logcat import dev.zacsweers.metro.ContributesTo import dev.zacsweers.metro.Provides @@ -83,7 +84,12 @@ internal class ConsentAwareEventTrackingClient( } private fun applyCollectionEnabled() { - val effective = synchronized(lock) { collectionRequestedEnabled && consent != AnalyticsConsent.DENIED } + val (effective, currentConsent) = synchronized(lock) { + (collectionRequestedEnabled && consent != AnalyticsConsent.DENIED) to consent + } + logcat(LogPriority.DEBUG, tag = ANALYTICS_CONSENT_DEBUG_TAG) { + "consent=$currentConsent -> firebase analytics collection enabled=$effective" + } delegate.setCollectionEnabled(effective) } @@ -161,6 +167,9 @@ internal class ConsentAwareEventTrackingClient( companion object { private const val MAX_BUFFERED_CALLS = 200 + + // Greppable tag for verifying analytics consent on-device: `adb logcat -s AnalyticsConsentDebug`. + private const val ANALYTICS_CONSENT_DEBUG_TAG = "AnalyticsConsentDebug" } } From 77d9df571ab330936f7fe679214f1f8360a621ae Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 12 Aug 2026 10:40:48 +0200 Subject: [PATCH 110/119] QA: mark analytics consent gate verified; note pinning bug fixed --- docs/superpowers/2026-07-24-onboarding-qa-checklist.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/2026-07-24-onboarding-qa-checklist.md b/docs/superpowers/2026-07-24-onboarding-qa-checklist.md index 1b04564185..3509ceb447 100644 --- a/docs/superpowers/2026-07-24-onboarding-qa-checklist.md +++ b/docs/superpowers/2026-07-24-onboarding-qa-checklist.md @@ -39,10 +39,13 @@ Reset between runs: Profile > About app > **Reset onboarding** (non-production r - [X] Underlined "Privacy policy" + northeast arrow opens the language-correct URL - Opened hedvig.com/se-en/... "Hedvig Privacy Policy" in the browser. - [X] Allow and Deny are two identical grey pills, Allow on top -- [ ] Allow advances; Settings > Product analytics shows "On" -- [ ] Deny advances; Settings shows "Off" +- [X] Allow advances; Settings > Product analytics shows "On" + - Verified via the AnalyticsConsentDebug log (`consent=GRANTED -> collection enabled=true`) and Settings > Usage data showing "Turned on". +- [X] Deny advances; Settings shows "Off" + - `consent=DENIED -> collection enabled=false`; Settings > Usage data showing "Disabled". - [ ] Decide nothing (dismiss flow first): Settings shows "Not set" - [ ] DebugView: pre-decision events buffered, arrive with `buffered_at_epoch_ms` on Allow + - Consent GATE (on/off) verified via logcat; the event-level buffering/`buffered_at_epoch_ms` check still needs DebugView. - [ ] DebugView: Deny stops everything including `session_start` (SDK collection off); Datadog still reports ### Phone @@ -93,6 +96,7 @@ Reset between runs: Profile > About app > **Reset onboarding** (non-production r ## D. Regression edges - [ ] Settings consent row round-trips On / Off / Not set; DebugView agrees + - On/Off round-trip confirmed (Settings > Usage data toggle, agrees with the AnalyticsConsentDebug log). "Not set" and DebugView agreement still open. - [ ] Standalone flows unaffected: edit co-insured, chip-id, Trustly, Forever tab, profile settings - [ ] TalkBack: back/close announce "Go back" / "Close" with 40dp+ targets - [ ] Known: repo-wide `ktlintCheck` fails on `feature-payments` (pre-existing on develop, not this branch) @@ -102,4 +106,4 @@ Reset between runs: Profile > About app > **Reset onboarding** (non-production r - Connect payment: a Trustly abort at the very first Trustly screen still registers a PENDING method on staging, so the step returns showing "connected" (Continue). This is the deliberate behavior, but it means the "Do this later" reveal is hard to reproduce on-device from a normal abort. - Drive was done on a member whose path was Welcome → Consent → Phone → Theme → Invite → Connect payment → Bundle (7 segments): no co-insured or pet-ID step, so those steps were not reachable in this session. Co-insured Add→return still needs an on-device pass with a member missing co-insured info. - Copy: bundle step subtitle says "15% bundle discount" on-device vs "10%" in the Figma frame. Confirm which is intended (looks like a config/copy value, not a bug). -- **BUG — completed pet-ID (and co-insured) row vanishes instead of staying with a checkmark.** Repro: single missing pet, tap Add, enter an ID, Save. On return the row disappears; the step shows an empty list. Root cause: the "pinned contract ids" that keep completed rows visible live in the presenter's `remember`, but `MoleculeViewModel` shares with `SharingStarted.WhileSubscribed(5.seconds)`, so a >5s trip into the chip-id/Trustly flow disposes the presenter and resets the pin to the now-empty missing-list. A quick abort (<5s) keeps the row; a normal completion loses it. Same mechanism also resets the connect-payment `hasAttemptedToConnect` (skip-after-attempt reveal) and the invite one-shot animation flag. Fix: hold the pin in a longer-lived scope (e.g. OnboardingSessionStore, ActivityRetainedScope) rather than presenter `remember`. +- **BUG (FIXED, needs on-device re-check) — completed pet-ID / co-insured row vanished instead of staying with a checkmark.** Repro: single missing pet, tap Add, enter an ID, Save; on return the row disappeared. Root cause: the "pinned contract ids" that keep completed rows visible lived in the presenter's `remember`, but `MoleculeViewModel` shares with `SharingStarted.WhileSubscribed(5.seconds)`, so a >5s trip into the chip-id/edit flow disposed the presenter and re-pinned from the now-empty missing-list. Fix (commit `bf838153c2`): the pin now lives in `OnboardingSession` (held by the `ActivityRetainedScope` session store, captured at first load, preserved across `refreshData`). Re-verify on a fresh member with a still-missing pet (the QA member's pet is now completed). The same reset mechanism still affects the connect-payment `hasAttemptedToConnect` skip reveal and the invite one-shot animation flag — not yet moved. From 4f54fd60c32f1979cf246114eed5b90255e13962 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 12 Aug 2026 10:42:41 +0200 Subject: [PATCH 111/119] Remove the analytics-consent QA debug log now that the gate is verified --- .../firebase/ConsentAwareEventTrackingClient.kt | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/app/tracking/tracking-firebase/src/main/kotlin/com/hedvig/android/tracking/firebase/ConsentAwareEventTrackingClient.kt b/app/tracking/tracking-firebase/src/main/kotlin/com/hedvig/android/tracking/firebase/ConsentAwareEventTrackingClient.kt index 27dd426e7f..02b4069d76 100644 --- a/app/tracking/tracking-firebase/src/main/kotlin/com/hedvig/android/tracking/firebase/ConsentAwareEventTrackingClient.kt +++ b/app/tracking/tracking-firebase/src/main/kotlin/com/hedvig/android/tracking/firebase/ConsentAwareEventTrackingClient.kt @@ -6,7 +6,6 @@ import com.hedvig.android.core.common.di.IoDispatcher import com.hedvig.android.core.tracking.EventTrackingClient import com.hedvig.android.data.settings.datastore.AnalyticsConsent import com.hedvig.android.data.settings.datastore.SettingsDataStore -import com.hedvig.android.logger.LogPriority import com.hedvig.android.logger.logcat import dev.zacsweers.metro.ContributesTo import dev.zacsweers.metro.Provides @@ -84,12 +83,7 @@ internal class ConsentAwareEventTrackingClient( } private fun applyCollectionEnabled() { - val (effective, currentConsent) = synchronized(lock) { - (collectionRequestedEnabled && consent != AnalyticsConsent.DENIED) to consent - } - logcat(LogPriority.DEBUG, tag = ANALYTICS_CONSENT_DEBUG_TAG) { - "consent=$currentConsent -> firebase analytics collection enabled=$effective" - } + val effective = synchronized(lock) { collectionRequestedEnabled && consent != AnalyticsConsent.DENIED } delegate.setCollectionEnabled(effective) } @@ -167,9 +161,6 @@ internal class ConsentAwareEventTrackingClient( companion object { private const val MAX_BUFFERED_CALLS = 200 - - // Greppable tag for verifying analytics consent on-device: `adb logcat -s AnalyticsConsentDebug`. - private const val ANALYTICS_CONSENT_DEBUG_TAG = "AnalyticsConsentDebug" } } From 461e4b25ce315fff8587c0d5ec9196a7b2268304 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 12 Aug 2026 12:23:15 +0200 Subject: [PATCH 112/119] QA: co-owners branch + pinning fix re-verified on-device; resolve 15% copy --- .../2026-07-24-onboarding-qa-checklist.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/2026-07-24-onboarding-qa-checklist.md b/docs/superpowers/2026-07-24-onboarding-qa-checklist.md index 3509ceb447..00762dcfd1 100644 --- a/docs/superpowers/2026-07-24-onboarding-qa-checklist.md +++ b/docs/superpowers/2026-07-24-onboarding-qa-checklist.md @@ -66,11 +66,11 @@ Reset between runs: Profile > About app > **Reset onboarding** (non-production r ### Co-insured / Pet ID - [X] Centered grey cards with contract pillow images, dark pill "Add" - - Pet-ID: "Dog Insurance Standard / Jackk" card with pillow + dark "Add" pill. -- [ ] Add opens the existing flow; completing it returns to the step with a checkmark on that card (row does not vanish) - - Add opens the chip-id flow ✓, and completing it saves the ID (Add gone) ✓, BUT the completed row VANISHES instead of showing the checkmark. See found issue below — regression from the WhileSubscribed(5s) presenter timeout defeating the pin. + - Pet-ID and co-owners both: contract card with pillow + dark "Add" pill. Co-owners step header correctly reads "Add co-owners". +- [X] Add opens the existing flow; completing it returns to the step with a checkmark on that card (row does not vanish) + - RE-VERIFIED after the pinning fix: completed a pet's chip-id while deliberately staying >5s in the flow (the original repro); the row stays and "Add" is replaced by the checkmark. Co-insured (co-owners) step also driven on-device. - [X] Aborting the external flow returns with the row unchanged - - Back from the chip-id flow returns to the pet-ID step with the "Add" row intact (chip-id nav fix works). + - Back from the chip-id flow (pet-ID) and from the edit-co-insured flow (co-owners) returns to the step with the "Add" row intact. - [X] Fine-print hint above the buttons - "You can add this information later" above Continue / Do this later. @@ -105,5 +105,5 @@ Reset between runs: Profile > About app > **Reset onboarding** (non-production r - Connect payment: a Trustly abort at the very first Trustly screen still registers a PENDING method on staging, so the step returns showing "connected" (Continue). This is the deliberate behavior, but it means the "Do this later" reveal is hard to reproduce on-device from a normal abort. - Drive was done on a member whose path was Welcome → Consent → Phone → Theme → Invite → Connect payment → Bundle (7 segments): no co-insured or pet-ID step, so those steps were not reachable in this session. Co-insured Add→return still needs an on-device pass with a member missing co-insured info. -- Copy: bundle step subtitle says "15% bundle discount" on-device vs "10%" in the Figma frame. Confirm which is intended (looks like a config/copy value, not a bug). -- **BUG (FIXED, needs on-device re-check) — completed pet-ID / co-insured row vanished instead of staying with a checkmark.** Repro: single missing pet, tap Add, enter an ID, Save; on return the row disappeared. Root cause: the "pinned contract ids" that keep completed rows visible lived in the presenter's `remember`, but `MoleculeViewModel` shares with `SharingStarted.WhileSubscribed(5.seconds)`, so a >5s trip into the chip-id/edit flow disposed the presenter and re-pinned from the now-empty missing-list. Fix (commit `bf838153c2`): the pin now lives in `OnboardingSession` (held by the `ActivityRetainedScope` session store, captured at first load, preserved across `refreshData`). Re-verify on a fresh member with a still-missing pet (the QA member's pet is now completed). The same reset mechanism still affects the connect-payment `hasAttemptedToConnect` skip reveal and the invite one-shot animation flag — not yet moved. +- Copy: bundle step subtitle says "15% bundle discount" on-device vs "10%" in the Figma frame. RESOLVED: 15% is correct, the Figma frame is wrong. No code change (the "15%" lives in the Lokalise string `ONBOARDING_CROSS_SELL_SUBTITLE`). +- **BUG (FIXED and re-verified on-device) — completed pet-ID / co-insured row vanished instead of staying with a checkmark.** Repro: single missing pet, tap Add, enter an ID, Save; on return the row disappeared. Root cause: the "pinned contract ids" that keep completed rows visible lived in the presenter's `remember`, but `MoleculeViewModel` shares with `SharingStarted.WhileSubscribed(5.seconds)`, so a >5s trip into the chip-id/edit flow disposed the presenter and re-pinned from the now-empty missing-list. Fix (commit `bf838153c2`): the pin now lives in `OnboardingSession` (held by the `ActivityRetainedScope` session store, captured at first load, preserved across `refreshData`). Re-verify on a fresh member with a still-missing pet (the QA member's pet is now completed). The same reset mechanism still affects the connect-payment `hasAttemptedToConnect` skip reveal and the invite one-shot animation flag — not yet moved. From eb5797015659558837a5c464aa1b13136e2ca56d Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 12 Aug 2026 13:33:03 +0200 Subject: [PATCH 113/119] chore: roll onboarding cross-sell subtitle instead of wrapping Long subtitles (common in English) wrapped to a second line, giving the bundle-discount cross-sell rows varying heights and uneven pillow spacing. Make the subtitle a single marquee line so it scrolls instead. Titles are left free to wrap. Inter-row spacing is unchanged. --- .../ui/bundle/OnboardingBundleDestination.kt | 34 ++++++++++++++++--- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt index c21ffbad0c..3eb4c99cbb 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt @@ -1,5 +1,6 @@ package com.hedvig.android.feature.onboarding.ui.bundle +import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -238,6 +239,8 @@ private fun OnboardingCrossSellRow( crossSell.description, style = HedvigTheme.typography.label, color = HedvigTheme.colorScheme.textSecondary, + maxLines = 1, + modifier = Modifier.basicMarquee(), ) } HedvigButton( @@ -280,19 +283,40 @@ private class OnboardingBundleUiStateProvider : CollectionPreviewParameterProvid progress = OnboardingProgress(totalSteps = 5, currentIndex = 1), crossSells = listOf( OnboardingCrossSell( - id = "accident", - title = "Accident Insurance", - description = "Coverage for accidental injuries", - storeUrl = "https://www.hedvig.com/se/forsakringar/olycksfallsforsakring", + id = "home", + title = "Home Insurance", + description = "For you, your family and your home", + storeUrl = "https://www.hedvig.com/se/forsakringar/hemforsakring", pillowImageUrl = null, ), OnboardingCrossSell( id = "pet", title = "Pet Insurance", - description = "Coverage for your pet", + description = "For your dog or cat", storeUrl = "https://www.hedvig.com/se/forsakringar/djurforsakring", pillowImageUrl = null, ), + OnboardingCrossSell( + id = "car", + title = "Car insurance", + description = "For you and your car", + storeUrl = "https://www.hedvig.com/se/forsakringar/bilforsakring", + pillowImageUrl = null, + ), + OnboardingCrossSell( + id = "vacation", + title = "Vacation Home Insurance", + description = "For your cottage or cabin", + storeUrl = "https://www.hedvig.com/se/forsakringar/fritidshusforsakring", + pillowImageUrl = null, + ), + OnboardingCrossSell( + id = "ppi", + title = "Payment Protection Insurance", + description = "For you if you get unemployed", + storeUrl = "https://www.hedvig.com/se/forsakringar/inkomstforsakring", + pillowImageUrl = null, + ), ), ), ), From 7dcf4fd292aa9c9c76ea81499e88656bf5e91b29 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 12 Aug 2026 14:07:38 +0200 Subject: [PATCH 114/119] chore: back-and-forth auto-scroll for cross-sell subtitle Replace basicMarquee with a custom AutoScrollingText that scrolls a too-wide subtitle to its end, pauses, then scrolls back to the start and pauses again, looping. basicMarquee only loops one direction with an arbitrary gap before the text re-enters from the right; this reveals the whole line and returns, matching the iOS behaviour. Scroll duration scales with the hidden overflow so the speed stays constant. Subtitles that already fit do not move. --- .../onboarding/ui/bundle/AutoScrollingText.kt | 77 +++++++++++++++++++ .../ui/bundle/OnboardingBundleDestination.kt | 11 ++- 2 files changed, 82 insertions(+), 6 deletions(-) create mode 100644 app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/AutoScrollingText.kt diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/AutoScrollingText.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/AutoScrollingText.kt new file mode 100644 index 0000000000..ef8e214f94 --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/AutoScrollingText.kt @@ -0,0 +1,77 @@ +package com.hedvig.android.feature.onboarding.ui.bundle + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import com.hedvig.android.design.system.hedvig.HedvigText +import kotlin.math.roundToInt +import kotlinx.coroutines.delay + +/** + * A single line of text that, when it is wider than the space it is given, scrolls left to reveal + * its end, pauses, then scrolls back to the start and pauses again, looping. Text that already fits + * stays still. Scroll duration is proportional to the hidden overflow, so longer text scrolls for + * longer at a constant reading speed rather than faster. + */ +@Composable +internal fun AutoScrollingText(text: String, style: TextStyle, color: Color, modifier: Modifier = Modifier) { + var containerWidth by remember { mutableIntStateOf(0) } + var contentWidth by remember { mutableIntStateOf(0) } + val offset = remember { Animatable(0f) } + val density = LocalDensity.current + + LaunchedEffect(containerWidth, contentWidth) { + val overflow = (contentWidth - containerWidth).toFloat() + if (overflow <= 0f) { + offset.snapTo(0f) + return@LaunchedEffect + } + val durationMillis = with(density) { (overflow / SCROLL_VELOCITY_PER_SECOND.toPx() * 1000f).roundToInt() } + val scrollSpec = tween(durationMillis, easing = LinearEasing) + while (true) { + delay(PAUSE_MILLIS) + offset.animateTo(-overflow, scrollSpec) + delay(PAUSE_MILLIS) + offset.animateTo(0f, scrollSpec) + } + } + + Box( + modifier = modifier + .clipToBounds() + .onSizeChanged { containerWidth = it.width }, + ) { + HedvigText( + text = text, + style = style, + color = color, + maxLines = 1, + softWrap = false, + modifier = Modifier + .wrapContentWidth(align = Alignment.Start, unbounded = true) + .onSizeChanged { contentWidth = it.width } + .offset { IntOffset(offset.value.roundToInt(), 0) }, + ) + } +} + +private val SCROLL_VELOCITY_PER_SECOND = 30.dp +private const val PAUSE_MILLIS = 1500L diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt index 3eb4c99cbb..51c0e94e06 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt @@ -1,6 +1,5 @@ package com.hedvig.android.feature.onboarding.ui.bundle -import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -232,17 +231,17 @@ private fun OnboardingCrossSellRow( } else { Spacer(Modifier.size(48.dp)) } - Spacer(Modifier.width(12.dp)) + Spacer(Modifier.width(16.dp)) Column(Modifier.weight(1f)) { HedvigText(crossSell.title, style = HedvigTheme.typography.bodySmall) - HedvigText( - crossSell.description, + AutoScrollingText( + text = crossSell.description, style = HedvigTheme.typography.label, color = HedvigTheme.colorScheme.textSecondary, - maxLines = 1, - modifier = Modifier.basicMarquee(), + modifier = Modifier.fillMaxWidth(), ) } + Spacer(Modifier.width(16.dp)) HedvigButton( text = stringResource(Res.string.ONBOARDING_SEE_PRICE_BUTTON), onClick = { openUrl(crossSell.storeUrl) }, From 1bc9a95a1bba06a21e6d3f613994ba8ef1ddde21 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 12 Aug 2026 14:19:28 +0200 Subject: [PATCH 115/119] chore: use typed Duration for auto-scroll pause delay --- .../feature/onboarding/ui/bundle/AutoScrollingText.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/AutoScrollingText.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/AutoScrollingText.kt index ef8e214f94..cdcc06027a 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/AutoScrollingText.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/AutoScrollingText.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import com.hedvig.android.design.system.hedvig.HedvigText import kotlin.math.roundToInt +import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.delay /** @@ -47,9 +48,9 @@ internal fun AutoScrollingText(text: String, style: TextStyle, color: Color, mod val durationMillis = with(density) { (overflow / SCROLL_VELOCITY_PER_SECOND.toPx() * 1000f).roundToInt() } val scrollSpec = tween(durationMillis, easing = LinearEasing) while (true) { - delay(PAUSE_MILLIS) + delay(PAUSE_MILLIS.milliseconds) offset.animateTo(-overflow, scrollSpec) - delay(PAUSE_MILLIS) + delay(PAUSE_MILLIS.milliseconds) offset.animateTo(0f, scrollSpec) } } From b3e580733c5e0157f6445f978f2368de8c82f62c Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 12 Aug 2026 14:46:58 +0200 Subject: [PATCH 116/119] chore: expose cross-sell auto-scroll as a Modifier Repackage the ping-pong auto-scroll as Modifier.autoScrollingMarquee(), a LayoutModifierNode that measures its content unbounded, reports the container width, clips, and animates the placement offset. Applies directly to HedvigText like basicMarquee, dropping the wrapping Box and the style/color pass-through. --- .../ui/bundle/AutoScrollingMarquee.kt | 103 ++++++++++++++++++ .../onboarding/ui/bundle/AutoScrollingText.kt | 78 ------------- .../ui/bundle/OnboardingBundleDestination.kt | 8 +- 3 files changed, 108 insertions(+), 81 deletions(-) create mode 100644 app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/AutoScrollingMarquee.kt delete mode 100644 app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/AutoScrollingText.kt diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/AutoScrollingMarquee.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/AutoScrollingMarquee.kt new file mode 100644 index 0000000000..cabb1243a5 --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/AutoScrollingMarquee.kt @@ -0,0 +1,103 @@ +package com.hedvig.android.feature.onboarding.ui.bundle + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.node.LayoutModifierNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.node.requireDensity +import androidx.compose.ui.platform.InspectorInfo +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlin.math.roundToInt +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** + * Scrolls the content horizontally when it is wider than the space it is given: left to reveal its + * end, a pause, then back to the start and another pause, looping. Content that already fits stays + * still. Scroll duration is proportional to the hidden overflow, so the speed stays constant rather + * than long content whipping past. Intended for a single, non-wrapping line of text. + * + * @param velocity how far the content travels per second while scrolling + * @param pause how long to hold at each end before scrolling the other way + */ +internal fun Modifier.autoScrollingMarquee( + velocity: Dp = DEFAULT_VELOCITY_PER_SECOND, + pause: Duration = DEFAULT_PAUSE, +): Modifier = clipToBounds() then AutoScrollingMarqueeElement(velocity, pause) + +private data class AutoScrollingMarqueeElement( + val velocity: Dp, + val pause: Duration, +) : ModifierNodeElement() { + override fun create() = AutoScrollingMarqueeNode(velocity, pause) + + override fun update(node: AutoScrollingMarqueeNode) = node.update(velocity, pause) + + override fun InspectorInfo.inspectableProperties() { + name = "autoScrollingMarquee" + properties["velocity"] = velocity + properties["pause"] = pause + } +} + +private class AutoScrollingMarqueeNode( + private var velocity: Dp, + private var pause: Duration, +) : Modifier.Node(), LayoutModifierNode { + private val offset = Animatable(0f) + private var overflow = 0f + private var animation: Job? = null + + fun update(velocity: Dp, pause: Duration) { + this.velocity = velocity + this.pause = pause + restart() + } + + override fun MeasureScope.measure(measurable: Measurable, constraints: Constraints): MeasureResult { + val placeable = measurable.measure(constraints.copy(maxWidth = Constraints.Infinity)) + val width = placeable.width.coerceAtMost(constraints.maxWidth) + val newOverflow = (placeable.width - width).toFloat() + if (newOverflow != overflow) { + overflow = newOverflow + restart() + } + return layout(width, placeable.height) { + placeable.place(offset.value.roundToInt(), 0) + } + } + + private fun restart() { + animation?.cancel() + animation = coroutineScope.launch { + if (overflow <= 0f) { + offset.snapTo(0f) + return@launch + } + val durationMillis = (overflow / with(requireDensity()) { velocity.toPx() } * 1000f).roundToInt() + val scrollSpec = tween(durationMillis, easing = LinearEasing) + offset.snapTo(0f) + while (isActive) { + delay(pause) + offset.animateTo(-overflow, scrollSpec) + delay(pause) + offset.animateTo(0f, scrollSpec) + } + } + } +} + +private val DEFAULT_VELOCITY_PER_SECOND = 30.dp +private val DEFAULT_PAUSE = 1500.milliseconds diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/AutoScrollingText.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/AutoScrollingText.kt deleted file mode 100644 index cdcc06027a..0000000000 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/AutoScrollingText.kt +++ /dev/null @@ -1,78 +0,0 @@ -package com.hedvig.android.feature.onboarding.ui.bundle - -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clipToBounds -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.dp -import com.hedvig.android.design.system.hedvig.HedvigText -import kotlin.math.roundToInt -import kotlin.time.Duration.Companion.milliseconds -import kotlinx.coroutines.delay - -/** - * A single line of text that, when it is wider than the space it is given, scrolls left to reveal - * its end, pauses, then scrolls back to the start and pauses again, looping. Text that already fits - * stays still. Scroll duration is proportional to the hidden overflow, so longer text scrolls for - * longer at a constant reading speed rather than faster. - */ -@Composable -internal fun AutoScrollingText(text: String, style: TextStyle, color: Color, modifier: Modifier = Modifier) { - var containerWidth by remember { mutableIntStateOf(0) } - var contentWidth by remember { mutableIntStateOf(0) } - val offset = remember { Animatable(0f) } - val density = LocalDensity.current - - LaunchedEffect(containerWidth, contentWidth) { - val overflow = (contentWidth - containerWidth).toFloat() - if (overflow <= 0f) { - offset.snapTo(0f) - return@LaunchedEffect - } - val durationMillis = with(density) { (overflow / SCROLL_VELOCITY_PER_SECOND.toPx() * 1000f).roundToInt() } - val scrollSpec = tween(durationMillis, easing = LinearEasing) - while (true) { - delay(PAUSE_MILLIS.milliseconds) - offset.animateTo(-overflow, scrollSpec) - delay(PAUSE_MILLIS.milliseconds) - offset.animateTo(0f, scrollSpec) - } - } - - Box( - modifier = modifier - .clipToBounds() - .onSizeChanged { containerWidth = it.width }, - ) { - HedvigText( - text = text, - style = style, - color = color, - maxLines = 1, - softWrap = false, - modifier = Modifier - .wrapContentWidth(align = Alignment.Start, unbounded = true) - .onSizeChanged { contentWidth = it.width } - .offset { IntOffset(offset.value.roundToInt(), 0) }, - ) - } -} - -private val SCROLL_VELOCITY_PER_SECOND = 30.dp -private const val PAUSE_MILLIS = 1500L diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt index 51c0e94e06..42fb9a9904 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt @@ -234,11 +234,13 @@ private fun OnboardingCrossSellRow( Spacer(Modifier.width(16.dp)) Column(Modifier.weight(1f)) { HedvigText(crossSell.title, style = HedvigTheme.typography.bodySmall) - AutoScrollingText( - text = crossSell.description, + HedvigText( + crossSell.description, style = HedvigTheme.typography.label, color = HedvigTheme.colorScheme.textSecondary, - modifier = Modifier.fillMaxWidth(), + maxLines = 1, + softWrap = false, + modifier = Modifier.autoScrollingMarquee(), ) } Spacer(Modifier.width(16.dp)) From 6d77c74f69445d37a8939622c65fba94575307aa Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 12 Aug 2026 14:56:51 +0200 Subject: [PATCH 117/119] chore: move autoScrollingMarquee into design-system-hedvig Promote Modifier.autoScrollingMarquee() from feature-onboarding to the shared design-system-hedvig commonMain so any surface can reuse it. Now public and KMP-common (available to iOS too). --- .../android/design/system/hedvig}/AutoScrollingMarquee.kt | 4 ++-- .../onboarding/ui/bundle/OnboardingBundleDestination.kt | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) rename app/{feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle => design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig}/AutoScrollingMarquee.kt (97%) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/AutoScrollingMarquee.kt b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/AutoScrollingMarquee.kt similarity index 97% rename from app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/AutoScrollingMarquee.kt rename to app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/AutoScrollingMarquee.kt index cabb1243a5..176ae3c9bc 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/AutoScrollingMarquee.kt +++ b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/AutoScrollingMarquee.kt @@ -1,4 +1,4 @@ -package com.hedvig.android.feature.onboarding.ui.bundle +package com.hedvig.android.design.system.hedvig import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.LinearEasing @@ -32,7 +32,7 @@ import kotlinx.coroutines.launch * @param velocity how far the content travels per second while scrolling * @param pause how long to hold at each end before scrolling the other way */ -internal fun Modifier.autoScrollingMarquee( +fun Modifier.autoScrollingMarquee( velocity: Dp = DEFAULT_VELOCITY_PER_SECOND, pause: Duration = DEFAULT_PAUSE, ): Modifier = clipToBounds() then AutoScrollingMarqueeElement(velocity, pause) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt index 42fb9a9904..dfa0cf390e 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt @@ -35,6 +35,7 @@ import com.hedvig.android.design.system.hedvig.HedvigPreview import com.hedvig.android.design.system.hedvig.HedvigText import com.hedvig.android.design.system.hedvig.HedvigTheme import com.hedvig.android.design.system.hedvig.Surface +import com.hedvig.android.design.system.hedvig.autoScrollingMarquee import com.hedvig.android.design.system.hedvig.placeholder.crossSellPainterFallback import com.hedvig.android.design.system.hedvig.rememberPreviewImageLoader import com.hedvig.android.feature.onboarding.data.OnboardingCrossSell From bf530f5a6aabb495314473810ecf304c5b6f8f4b Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 12 Aug 2026 15:14:28 +0200 Subject: [PATCH 118/119] chore: give onboarding cross-sell rows a uniform height Lay the cross-sell rows out with a small custom Layout that measures every row once and sizes them all to the tallest, centring each row's content in its cell. A single two-line title lifts every row to the same height, so the pillows and See price buttons keep an even vertical rhythm instead of drifting with each row's text. One measure pass, so heights are uniform on the first frame with no relayout. When no title wraps, the tallest equals the rest and nothing changes. --- .../ui/bundle/OnboardingBundleDestination.kt | 40 +++++++++++++++---- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt index dfa0cf390e..46eb161bb9 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt @@ -19,8 +19,11 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.Placeable import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.ImageLoader @@ -184,15 +187,14 @@ private fun OnboardingBundleScreen( ) Spacer(Modifier.weight(1f)) Spacer(Modifier.height(24.dp)) - for ((index, crossSell) in state.crossSells.withIndex()) { - if (index > 0) { - Spacer(Modifier.height(8.dp)) + EqualHeightColumn(rowSpacing = 8.dp) { + for (crossSell in state.crossSells) { + OnboardingCrossSellRow( + crossSell = crossSell, + imageLoader = imageLoader, + openUrl = openUrl, + ) } - OnboardingCrossSellRow( - crossSell = crossSell, - imageLoader = imageLoader, - openUrl = openUrl, - ) } Spacer(Modifier.height(8.dp)) Spacer(Modifier.weight(1f)) @@ -256,6 +258,28 @@ private fun OnboardingCrossSellRow( } } +/** + * Lays its children out in a vertical column where every child is given the height of the tallest + * child, with each child's own content centred within its cell. One measure pass, so the heights are + * uniform on the first frame. [rowSpacing] is inserted between children. + */ +@Composable +private fun EqualHeightColumn(rowSpacing: Dp, modifier: Modifier = Modifier, content: @Composable () -> Unit) { + Layout(content = content, modifier = modifier) { measurables, constraints -> + val placeables = measurables.map { it.measure(constraints.copy(minHeight = 0)) } + val rowHeight = placeables.maxOfOrNull(Placeable::height) ?: 0 + val spacingPx = rowSpacing.roundToPx() + val totalHeight = rowHeight * placeables.size + spacingPx * (placeables.size - 1).coerceAtLeast(0) + layout(constraints.maxWidth, totalHeight) { + var y = 0 + placeables.forEach { placeable -> + placeable.place(0, y + (rowHeight - placeable.height) / 2) + y += rowHeight + spacingPx + } + } + } +} + @HedvigPreview @Composable private fun PreviewOnboardingBundleScreen( From c1e1df9158c52dc9534320382445e2d2bc99f8af Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 12 Aug 2026 17:18:38 +0200 Subject: [PATCH 119/119] Run lint --- hedvig-lint/lint-baseline/lint-baseline-feature-onboarding.xml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 hedvig-lint/lint-baseline/lint-baseline-feature-onboarding.xml diff --git a/hedvig-lint/lint-baseline/lint-baseline-feature-onboarding.xml b/hedvig-lint/lint-baseline/lint-baseline-feature-onboarding.xml new file mode 100644 index 0000000000..699da1bff0 --- /dev/null +++ b/hedvig-lint/lint-baseline/lint-baseline-feature-onboarding.xml @@ -0,0 +1,3 @@ + + +