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/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 diff --git a/app/app/build.gradle.kts b/app/app/build.gradle.kts index e112bd29e9..5156ddcd2d 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..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 @@ -274,6 +278,8 @@ class MainActivity : AppCompatActivity() { currentDestinationHolder = currentDestinationHolder, eventTrackingClient = eventTrackingClient, screenParameterExtractor = screenParameterExtractor, + onboardingGate = navRetainedViewModel.onboardingGate, + resetOnboardingSeenUseCase = resetOnboardingSeenUseCase, ) } } 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 af6b954353..ee73f8632a 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 @@ -48,6 +48,8 @@ import com.hedvig.android.feature.login.navigation.loginEntries import com.hedvig.android.feature.movingflow.MovingSource 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 import com.hedvig.android.feature.payoutaccount.navigation.payoutAccountEntries @@ -57,6 +59,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.memberquickactions.InnerHelpCenterDestination @@ -97,6 +100,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) } @@ -167,6 +171,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, @@ -184,9 +195,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, @@ -463,6 +484,7 @@ private fun EntryProviderScope.addProfileEntries( navigateToConnectPayment: () -> Unit, navigateToPayoutAccount: () -> Unit, navigateToNewConversation: () -> Unit, + onResetOnboardingForDebug: () -> Unit, ) { profileEntries( settingsDestinationNestedGraphs = { @@ -489,8 +511,10 @@ 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, ) } @@ -531,6 +555,7 @@ private fun EntryProviderScope.addSharedFlowEntries( externalNavigator: ExternalNavigator, navigateToNewConversation: () -> Unit, navigateToInbox: () -> Unit, + openPrivacyPolicy: () -> Unit, ) { addonPurchaseEntries( backstack = backstack, @@ -566,4 +591,11 @@ private fun EntryProviderScope.addSharedFlowEntries( ) imageViewerEntries(backstack, imageLoader) removeAddonsEntries(backstack = backstack) + onboardingEntries( + backstack = backstack, + imageLoader = imageLoader, + 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..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 @@ -68,6 +68,10 @@ 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.data.ResetOnboardingSeenUseCase +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 +79,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 +120,8 @@ internal fun HedvigApp( currentDestinationHolder: CurrentDestinationHolder, eventTrackingClient: EventTrackingClient, screenParameterExtractor: ScreenParameterExtractor, + onboardingGate: OnboardingGate, + resetOnboardingSeenUseCase: ResetOnboardingSeenUseCase, ) { ReportCurrentDestinationEffect(backstackController, currentDestinationHolder) TrackScreenViewEffect(backstackController, eventTrackingClient, screenParameterExtractor) @@ -139,6 +146,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) { @@ -216,6 +228,7 @@ internal fun HedvigApp( imageLoader = imageLoader, languageService = languageService, hedvigBuildConstants = hedvigBuildConstants, + resetOnboardingSeenUseCase = resetOnboardingSeenUseCase, ) }, ) @@ -368,3 +381,38 @@ 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, 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 + // 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) + onboardingGate.markSeenWhenOnboardingDismissed() + } + } +} 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 new file mode 100644 index 0000000000..7187513927 --- /dev/null +++ b/app/core/core-resources/src/commonMain/composeResources/drawable/onboarding_verified_card.xml @@ -0,0 +1,15 @@ + + + + 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) + } +} 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/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/AutoScrollingMarquee.kt b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/AutoScrollingMarquee.kt new file mode 100644 index 0000000000..176ae3c9bc --- /dev/null +++ b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/AutoScrollingMarquee.kt @@ -0,0 +1,103 @@ +package com.hedvig.android.design.system.hedvig + +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 + */ +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/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..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 @@ -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,84 +34,47 @@ 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. +private const val dotStaggerMilliseconds = 200 private const val numberOfDots = 3 -// @HedvigPreview -@Preview +@HedvigPreview @Composable private fun PreviewThreeDotsLoading() { HedvigTheme { 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 diff --git a/app/feature/feature-onboarding/build.gradle.kts b/app/feature/feature-onboarding/build.gradle.kts new file mode 100644 index 0000000000..dd38876cd6 --- /dev/null +++ b/app/feature/feature-onboarding/build.gradle.kts @@ -0,0 +1,56 @@ +plugins { + id("hedvig.android.library") + id("hedvig.gradle.plugin") +} + +hedvig { + apollo("octopus") + serialization() + compose() + navKeys() + viewModels() +} + +android { + // Compose's Recomposer logs via android.util.Log in unit tests; return defaults instead of throwing. + testOptions.unitTests.isReturnDefaultValues = true +} + +dependencies { + implementation(libs.apollo.normalizedCache) + implementation(libs.apollo.runtime) + implementation(libs.arrow.core) + 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) + implementation(libs.coil.compose) + implementation(projects.dataContract) + implementation(projects.dataSettingsDatastorePublic) + implementation(projects.designSystemHedvig) + implementation(projects.dataCoinsured) + implementation(projects.featureConnectPaymentTrustlyNavigation) + implementation(projects.featureEditCoinsuredNavigation) + implementation(projects.featureFlags) + implementation(projects.foreverUi) + 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.loggingTest) + testImplementation(projects.moleculeTest) +} 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..893a063104 --- /dev/null +++ b/app/feature/feature-onboarding/src/main/graphql/OnboardingQuery.graphql @@ -0,0 +1,51 @@ +query Onboarding { + currentMember { + id + email + phoneNumber + activeContracts { + id + exposureDisplayNameShort + isMissingPetId + supportsCoInsured + supportsCoOwners + coInsured { + hasMissingInfo + terminatesOn + } + coOwners { + hasMissingInfo + terminatesOn + } + 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 + pillowImageLarge { + src + } + } + } + } +} 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/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/OnboardingData.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingData.kt new file mode 100644 index 0000000000..44e344953d --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingData.kt @@ -0,0 +1,74 @@ +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. + */ +internal data class OnboardingData( + val email: String, + val phoneNumber: String?, + val contracts: List, + val referralInformation: OnboardingReferralInformation?, + val payinStatus: OnboardingPayinStatus, + val crossSells: List, +) { + /** 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 } + + 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( + val id: String, + val displayName: String, + val exposureName: String, + val typeOfContract: String, + val missingCoInsuredCount: Int, + val missingCoOwnersCount: Int, + val isMissingPetId: Boolean, +) { + /** + * 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, + val monthlyDiscountPerReferralAmount: Double, + val currencyCode: String, +) + +internal data class OnboardingCrossSell( + val id: String, + 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/OnboardingPath.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingPath.kt new file mode 100644 index 0000000000..d726f7ac88 --- /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.contractsMissingInsuredOrOwnerInfo.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/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..7cdde61762 --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingRepository.kt @@ -0,0 +1,98 @@ +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, + // 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 { + 0 + }, + missingCoOwnersCount = if (contract.supportsCoOwners) { + contract.coOwners.orEmpty().count { it.hasMissingInfo && it.terminatesOn == null } + } else { + 0 + }, + isMissingPetId = contract.isMissingPetId, + ) + }, + referralInformation = member.referralInformation.let { referralInformation -> + OnboardingReferralInformation( + code = referralInformation.code, + monthlyDiscountPerReferralAmount = referralInformation.monthlyDiscountPerReferral.amount, + currencyCode = referralInformation.monthlyDiscountPerReferral.currencyCode.rawValue, + ) + }, + 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, + title = crossSell.title, + description = crossSell.description, + storeUrl = crossSell.storeUrl, + pillowImageUrl = crossSell.pillowImageLarge.src, + ) + }, + ) + } + + 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) } + } +} 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..f47f365e78 --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingSeenStore.kt @@ -0,0 +1,49 @@ +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) + + /** Debug affordance only: clears the flag so onboarding can be triggered again. */ + suspend fun resetOnboardingSeen(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 } + } + + 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/OnboardingSessionStore.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingSessionStore.kt new file mode 100644 index 0000000000..0ab669b27b --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingSessionStore.kt @@ -0,0 +1,104 @@ +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.data.coinsured.CoInsuredFlowType +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 + + val currentSession: OnboardingSession? + get() = cachedSession + + suspend fun getOrFetchSession(): Either = mutex.withLock { + 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( + 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 } + } + } + + /** + * 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 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 } + } + } +} + +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/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/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..6126212b73 --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/gate/OnboardingGate.kt @@ -0,0 +1,91 @@ +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 +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 + * [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 + + /** + * 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) +@SingleIn(ActivityRetainedScope::class) +@Inject +internal class OnboardingGateImpl( + private val memberIdService: MemberIdService, + 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 { + // 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 + } ?: false + if (!flagsAvailable) { + logcat(LogPriority.INFO) { "No flag value available yet, 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 + } + 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() }, + ) + } + + 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() + } +} + +// 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/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..fb5457925e --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingEntries.kt @@ -0,0 +1,89 @@ +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 +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 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( + backstack: Backstack, + imageLoader: ImageLoader, + openUrl: (String) -> Unit, + openPrivacyPolicy: () -> Unit, + navigateToChipId: (contractId: String) -> Unit, +) { + entry { + val viewModel: OnboardingWelcomeViewModel = metroViewModel() + OnboardingWelcomeDestination(viewModel) + } + + entry { + val viewModel: ForeverViewModel = metroViewModel() + ForeverDestination(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, imageLoader, backstack::navigateUp, openUrl) + } + } + } +} 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..a2b4fbf343 --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingKeys.kt @@ -0,0 +1,47 @@ +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 + +/** + * 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. + */ +enum class OnboardingStepId { + AnalyticsConsent, + PhoneNumber, + Theme, + CoInsured, + PetIds, + InviteFriend, + ConnectPayment, + BundleDiscount, +} 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..0069840000 --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingNavigator.kt @@ -0,0 +1,69 @@ +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 +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() + } + + /** 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. */ + 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/OnboardingContractCard.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingContractCard.kt new file mode 100644 index 0000000000..2afe51e479 --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingContractCard.kt @@ -0,0 +1,101 @@ +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 +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.hedvigDropShadow +import com.hedvig.android.design.system.hedvig.icon.Checkmark +import com.hedvig.android.design.system.hedvig.icon.HedvigIcons +import hedvig.resources.ONBOARDING_ADD_BUTTON +import hedvig.resources.Res +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource + +@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) + .hedvigDropShadow(HedvigTheme.shapes.cornerLarge), + ) { + 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.label, + color = HedvigTheme.colorScheme.textSecondary, + ) + } + Spacer(Modifier.width(12.dp)) + if (isComplete) { + 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), + 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/OnboardingProgressBarAnimation.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingProgressBarAnimation.kt new file mode 100644 index 0000000000..5d92f67212 --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingProgressBarAnimation.kt @@ -0,0 +1,64 @@ +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 + * 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 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 + * [com.hedvig.android.feature.onboarding.data.OnboardingSessionStore]. + */ +@Inject +@SingleIn(ActivityRetainedScope::class) +internal class OnboardingProgressBarAnimation { + private val visibleSteps = mutableStateMapOf() + + /** + * 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 stepNumberSum = 0f + var visibleSum = 0f + for (step in visibleSteps.values) { + stepNumberSum += step.stepNumber * step.visibleAmount + visibleSum += step.visibleAmount + } + 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 (visibleSteps.isEmpty()) return 0f + return visibleSteps.values.sumOf { it.stepNumber.toDouble() }.toFloat() / visibleSteps.size + } + + fun setVisibleStep(key: Any, stepNumber: Int, visibleAmount: Float) { + visibleSteps[key] = VisibleStep(stepNumber, visibleAmount) + } + + fun removeStep(key: Any) { + visibleSteps.remove(key) + } + + private data class VisibleStep(val stepNumber: Int, val visibleAmount: Float) + + private companion object { + 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 new file mode 100644 index 0000000000..e8c9e9a0d4 --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/OnboardingStepScaffold.kt @@ -0,0 +1,337 @@ +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 +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.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 +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 +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.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 +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, + 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, + ) +} + +/** + * 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?, + showBackButton: Boolean, + 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 and the progress bar draws its fill statically. + val sharedTransitionScope = LocalSharedTransitionScope.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 = animatedVisibilityScope, + ) + } + } else { + Modifier + } + Surface( + color = HedvigTheme.colorScheme.backgroundPrimary, + modifier = modifier.fillMaxSize(), + ) { + Column( + Modifier.windowInsetsPadding( + WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top), + ), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = headerModifier + .fillMaxWidth() + .height(56.dp) + .padding(horizontal = 16.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( + progress = progress, + animation = progressAnimation, + animatedVisibilityScope = animatedVisibilityScope, + modifier = Modifier + .weight(1f) + .padding(horizontal = 24.dp), + ) + } else { + Spacer(Modifier.weight(1f)) + } + IconButton( + onClick = onCloseClick, + ) { + Icon( + imageVector = HedvigIcons.Close, + contentDescription = stringResource(Res.string.general_close_button), + modifier = Modifier.size(24.dp), + ) + } + } + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()), + content = content, + ) + } + } +} + +@Composable +private fun OnboardingProgressBar( + progress: OnboardingProgress, + animation: OnboardingProgressBarAnimation?, + animatedVisibilityScope: AnimatedVisibilityScope?, + modifier: Modifier = Modifier, +) { + // 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 = "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 { + // 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 -> + // 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) + .height(4.dp) + .clip(CircleShape) + .background(HedvigTheme.colorScheme.surfaceSecondary), + ) { + if (sliceFill > 0f) { + Box( + Modifier + .fillMaxWidth(sliceFill) + .height(4.dp) + .clip(CircleShape) + .background(HedvigTheme.colorScheme.fillPrimary), + ) + } + } + } + } +} + +@Composable +internal fun OnboardingStepHeader(title: String, modifier: Modifier = Modifier, description: String? = null) { + Column(modifier.padding(horizontal = 16.dp)) { + HedvigText(text = title) + if (description != null) { + Spacer(Modifier.height(4.dp)) + HedvigText(text = description, color = HedvigTheme.colorScheme.textSecondary) + } + } +} + +/** Bottom-anchored button area with full-pill shapes. Primary and optional secondary buttons. */ +@Composable +internal fun ColumnScope.OnboardingStepButtons( + primaryText: String, + onPrimaryClick: () -> Unit, + primaryEnabled: Boolean = true, + secondaryText: String? = null, + onSecondaryClick: (() -> Unit)? = null, + secondaryAbovePrimary: Boolean = false, +) { + Spacer(Modifier.height(16.dp)) + val primaryButton = @Composable { + HedvigButton( + text = primaryText, + onClick = onPrimaryClick, + enabled = primaryEnabled, + modifier = Modifier + .fillMaxWidth() + .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))) +} + +@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.", + ) + Spacer(Modifier.weight(1f)) + 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.", + ) + 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 new file mode 100644 index 0000000000..46eb161bb9 --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundleDestination.kt @@ -0,0 +1,349 @@ +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.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 +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.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 +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.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 +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_CONTINUE_TO_APP_BUTTON +import hedvig.resources.ONBOARDING_CROSS_SELL_SUBTITLE +import hedvig.resources.ONBOARDING_CROSS_SELL_TITLE +import hedvig.resources.ONBOARDING_SEE_PRICE_BUTTON +import hedvig.resources.Res +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource + +@Inject +@HedvigViewModel(ActivityRetainedScope::class) +internal class OnboardingBundleViewModel( + sessionStore: OnboardingSessionStore, + navigator: OnboardingNavigator, + val progressBarAnimation: OnboardingProgressBarAnimation, +) : 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, + imageLoader: ImageLoader, + navigateUp: () -> Unit, + 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 = onClose, + progressAnimation = progressAnimation, + ) { + when (val state = uiState) { + OnboardingBundleUiState.Loading -> { + HedvigFullScreenCenterAlignedProgressDebounced() + } + + OnboardingBundleUiState.Error -> { + HedvigErrorSection( + onButtonClick = onRetry, + ) + } + + is OnboardingBundleUiState.Content -> { + Spacer(Modifier.height(16.dp)) + OnboardingStepHeader( + title = stringResource(Res.string.ONBOARDING_CROSS_SELL_TITLE), + description = stringResource(Res.string.ONBOARDING_CROSS_SELL_SUBTITLE), + ) + Spacer(Modifier.weight(1f)) + Spacer(Modifier.height(24.dp)) + EqualHeightColumn(rowSpacing = 8.dp) { + for (crossSell in state.crossSells) { + OnboardingCrossSellRow( + crossSell = crossSell, + imageLoader = imageLoader, + openUrl = openUrl, + ) + } + } + Spacer(Modifier.height(8.dp)) + Spacer(Modifier.weight(1f)) + Spacer(Modifier.height(24.dp)) + OnboardingStepButtons( + primaryText = stringResource(Res.string.ONBOARDING_CONTINUE_TO_APP_BUTTON), + onPrimaryClick = onContinueToApp, + ) + } + } + } +} + +@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(48.dp), + ) + } else { + Spacer(Modifier.size(48.dp)) + } + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f)) { + HedvigText(crossSell.title, style = HedvigTheme.typography.bodySmall) + HedvigText( + crossSell.description, + style = HedvigTheme.typography.label, + color = HedvigTheme.colorScheme.textSecondary, + maxLines = 1, + softWrap = false, + modifier = Modifier.autoScrollingMarquee(), + ) + } + Spacer(Modifier.width(16.dp)) + HedvigButton( + text = stringResource(Res.string.ONBOARDING_SEE_PRICE_BUTTON), + onClick = { openUrl(crossSell.storeUrl) }, + enabled = true, + buttonStyle = ButtonDefaults.ButtonStyle.Secondary, + buttonSize = ButtonDefaults.ButtonSize.Small, + modifier = Modifier.clip(CircleShape), + ) + } +} + +/** + * 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( + @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 = "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 = "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, + ), + ), + ), + ), +) 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..bbaf5045b6 --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredDestination.kt @@ -0,0 +1,217 @@ +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.getValue +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.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.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 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 org.jetbrains.compose.resources.stringResource + +@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 {} + } + + 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 = onClose, + progressAnimation = progressAnimation, + ) { + when (val content = uiState) { + OnboardingCoInsuredUiState.Loading -> { + HedvigFullScreenCenterAlignedProgressDebounced() + } + + OnboardingCoInsuredUiState.Error -> { + HedvigErrorSection( + onButtonClick = onRetry, + ) + } + + is OnboardingCoInsuredUiState.Content -> { + // 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( + 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)) + Spacer(Modifier.height(24.dp)) + 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 = { onAddCoInsured(row.contractId, row.flowType) }, + ) + } + } + Spacer(Modifier.weight(1f)) + Spacer(Modifier.height(24.dp)) + HedvigText( + text = stringResource(Res.string.ONBOARDING_ADD_INFO_LATER_LABEL), + style = HedvigTheme.typography.finePrint, + color = HedvigTheme.colorScheme.textSecondary, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + OnboardingStepButtons( + primaryText = stringResource(Res.string.general_continue_button), + onPrimaryClick = onContinue, + secondaryText = stringResource(Res.string.ONBOARDING_DO_THIS_LATER_BUTTON), + 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/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..9a4e338cac --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredViewModel.kt @@ -0,0 +1,126 @@ +package com.hedvig.android.feature.onboarding.ui.coinsured + +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 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.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.OnboardingProgressBarAnimation +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, + 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) } + + fun contentFrom(session: OnboardingSession): OnboardingCoInsuredUiState.Content { + val pinned = session.pinnedCoInsuredContracts + 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/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..7377a51608 --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingConsentDestination.kt @@ -0,0 +1,278 @@ +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 +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.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 +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.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 +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 +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 OnboardingConsentViewModel( + sessionStore: OnboardingSessionStore, + navigator: OnboardingNavigator, + settingsDataStore: SettingsDataStore, + val progressBarAnimation: OnboardingProgressBarAnimation, +) : 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() + 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 = onClose, + progressAnimation = progressAnimation, + ) { + when (uiState) { + OnboardingConsentUiState.Loading -> { + HedvigFullScreenCenterAlignedProgressDebounced() + } + + OnboardingConsentUiState.Error -> { + HedvigErrorSection( + onButtonClick = onRetry, + ) + } + + is OnboardingConsentUiState.Content -> { + Spacer(Modifier.height(16.dp)) + OnboardingStepHeader( + title = stringResource(Res.string.ONBOARDING_ANALYTICS_TITLE), + description = stringResource(Res.string.ONBOARDING_ANALYTICS_SUBTITLE), + ) + Spacer(Modifier.weight(1f)) + Spacer(Modifier.height(24.dp)) + OnboardingVerifiedCard(modifier = Modifier.align(Alignment.CenterHorizontally)) + Spacer(Modifier.weight(1f)) + Spacer(Modifier.height(24.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .clip(CircleShape) + .clickable(onClick = openPrivacyPolicy) + .padding(horizontal = 8.dp, vertical = 4.dp), + ) { + HedvigText( + text = stringResource(Res.string.LEGAL_PRIVACY_POLICY), + style = HedvigTheme.typography.label, + textDecoration = TextDecoration.Underline, + ) + Icon( + imageVector = HedvigIcons.ArrowNorthEast, + contentDescription = null, + modifier = Modifier.size(18.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) + .clip(CircleShape), + ) + 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) + .clip(CircleShape), + ) + Spacer(Modifier.height(16.dp)) + Spacer(Modifier.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom))) + } + } + } +} + +@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/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..f117392754 --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingVerifiedCard.kt @@ -0,0 +1,118 @@ +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.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.hedvigDropShadow +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(1.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( + 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) + .hedvigDropShadow(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)) + } + } +} 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..37214a8c56 --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteDestination.kt @@ -0,0 +1,252 @@ +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.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.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.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 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 org.jetbrains.compose.resources.stringResource + +@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 = onClose, + progressAnimation = progressAnimation, + ) { + when (uiState) { + OnboardingInviteUiState.Loading -> { + HedvigFullScreenCenterAlignedProgressDebounced() + } + + OnboardingInviteUiState.Error -> { + HedvigErrorSection( + onButtonClick = onRetry, + ) + } + + is OnboardingInviteUiState.Content -> { + Spacer(Modifier.height(16.dp)) + OnboardingStepHeader( + title = stringResource(Res.string.ONBOARDING_INVITE_FRIEND_TITLE), + description = stringResource(Res.string.ONBOARDING_INVITE_FRIEND_SUBTITLE, uiState.incentiveDisplay), + ) + Spacer(Modifier.weight(1f)) + Spacer(Modifier.height(24.dp)) + // 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 = uiState.incentiveDisplay, + animationAlreadyPlayed = uiState.inviteCardAnimationPlayed, + onAnimationCompleted = onInviteCardAnimationCompleted, + modifier = Modifier + .widthIn(max = 280.dp) + .fillMaxWidth(), + ) + } + Spacer(Modifier.weight(1f)) + Spacer(Modifier.height(24.dp)) + OnboardingStepButtons( + primaryText = stringResource(Res.string.general_continue_button), + onPrimaryClick = onContinue, + secondaryText = stringResource(Res.string.ONBOARDING_INVITE_FRIEND_TITLE), + secondaryAbovePrimary = true, + onSecondaryClick = onInviteFriend, + ) + } + } + } +} + +@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.milliseconds) + visibleRows = 2 + delay(RowRevealStaggerDelayMillis.milliseconds) + visibleRows = 3 + onAnimationCompleted() + } + Surface( + modifier = modifier.hedvigDropShadow(HedvigTheme.shapes.cornerLarge), + shape = HedvigTheme.shapes.cornerLarge, + color = HedvigTheme.colorScheme.backgroundPrimary, + border = HedvigTheme.colorScheme.borderPrimary, + ) { + Column(Modifier.padding(horizontal = 16.dp)) { + exampleNames.forEachIndexed { index, name -> + if (index == 0) { + ExampleReferralRow(name = name, incentiveDisplay = incentiveDisplay) + } else { + AnimatedVisibility( + visible = visibleRows > index, + // 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()), + ) { + ExampleReferralRow( + name = name, + incentiveDisplay = incentiveDisplay, + modifier = Modifier.horizontalDivider( + position = DividerPosition.Top, + color = HedvigTheme.colorScheme.borderPrimary, + ), + ) + } + } + } + } + } +} + +@Composable +private fun ExampleReferralRow(name: String, incentiveDisplay: String, modifier: Modifier = Modifier) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier.padding(vertical = 16.dp), + ) { + Box( + Modifier + .size(16.dp) + .clip(CircleShape) + .background(HedvigTheme.colorScheme.signalGreenElement), + ) + Spacer(Modifier.width(8.dp)) + // 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) + } +} + +private const val RowRevealInitialDelayMillis = 550L +private const val RowRevealStaggerDelayMillis = 1000L +private const val RowRevealDurationMillis = MotionTokens.DurationLong4.toInt() + +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, + ), + ), +) 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..e0a94a357b --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInviteViewModel.kt @@ -0,0 +1,106 @@ +package com.hedvig.android.feature.onboarding.ui.invite + +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 com.hedvig.android.core.common.di.ActivityRetainedScope +import com.hedvig.android.core.common.di.HedvigViewModel +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.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, + 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/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..60af9df764 --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingConnectingPaymentSymbol.kt @@ -0,0 +1,162 @@ +package com.hedvig.android.feature.onboarding.ui.payment + +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.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.platform.LocalInspectionMode +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.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 +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 + +// How long after the graphic appears the connected check pops in. +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; 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, modifier: Modifier = Modifier) { + // 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 + return@LaunchedEffect + } + delay(CheckPopDelayMillis.milliseconds) + checkVisible = true + } + 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() + ThreeDotsLoading() + 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 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 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 new file mode 100644 index 0000000000..8109f40f97 --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt @@ -0,0 +1,298 @@ +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.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.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.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_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 +import org.jetbrains.compose.resources.stringResource + +@Inject +@HedvigViewModel(ActivityRetainedScope::class) +internal class OnboardingPaymentViewModel( + sessionStore: OnboardingSessionStore, + navigator: OnboardingNavigator, + val progressBarAnimation: OnboardingProgressBarAnimation, +) : 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) } + var hasAttemptedToConnect by remember { mutableStateOf(false) } + + 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), + payinStatus = session.data.payinStatus, + ) + }, + ) + } + + 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), + payinStatus = refreshed.data.payinStatus, + ) + } + } + } + + OnboardingPaymentEvent.ConnectPayment -> { + hasAttemptedToConnect = true + navigator.openConnectPayment() + } + + OnboardingPaymentEvent.Continue -> { + launch { navigator.continueFrom(OnboardingStepId.ConnectPayment) } + } + } + } + + return when (val state = currentState) { + is OnboardingPaymentUiState.Content -> state.copy(hasAttemptedToConnect = hasAttemptedToConnect) + else -> state + } + } +} + +internal sealed interface OnboardingPaymentUiState { + data object Loading : OnboardingPaymentUiState + + data object Error : 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 +} + +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 {} + } + + 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 = onClose, + progressAnimation = progressAnimation, + ) { + when (val content = uiState) { + OnboardingPaymentUiState.Loading -> { + HedvigFullScreenCenterAlignedProgressDebounced() + } + + OnboardingPaymentUiState.Error -> { + HedvigErrorSection( + onButtonClick = onRetry, + ) + } + + 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. 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 + 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. + OnboardingStepHeader( + title = stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_TITLE), + description = stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_SUBTITLE), + ) + Spacer(Modifier.weight(1f)) + OnboardingConnectingPaymentSymbol( + showCheck = isConnected, + 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, + secondaryText = if (canSkip) stringResource(Res.string.ONBOARDING_DO_THIS_LATER_BUTTON) else null, + onSecondaryClick = if (canSkip) onContinue else null, + ) + } + } + } +} + +@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.NeedsSetup, + hasAttemptedToConnect = true, + ), + 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/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..b87bbd86c7 --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdDestination.kt @@ -0,0 +1,184 @@ +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.getValue +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.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.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 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 org.jetbrains.compose.resources.stringResource + +@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 {} + } + + 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 = onClose, + progressAnimation = progressAnimation, + ) { + when (val content = uiState) { + OnboardingPetIdUiState.Loading -> { + HedvigFullScreenCenterAlignedProgressDebounced() + } + + OnboardingPetIdUiState.Error -> { + HedvigErrorSection( + onButtonClick = onRetry, + ) + } + + is OnboardingPetIdUiState.Content -> { + Spacer(Modifier.height(16.dp)) + OnboardingStepHeader( + title = stringResource(Res.string.ONBOARDING_ADD_PET_CHIP_IDS_TITLE), + description = stringResource(Res.string.ONBOARDING_MISSING_INFO_PET_SUBTITLE), + ) + Spacer(Modifier.weight(1f)) + Spacer(Modifier.height(24.dp)) + 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)) + Spacer(Modifier.height(24.dp)) + HedvigText( + text = stringResource(Res.string.ONBOARDING_ADD_INFO_LATER_LABEL), + style = HedvigTheme.typography.finePrint, + color = HedvigTheme.colorScheme.textSecondary, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + OnboardingStepButtons( + primaryText = stringResource(Res.string.general_continue_button), + onPrimaryClick = onContinue, + secondaryText = stringResource(Res.string.ONBOARDING_DO_THIS_LATER_BUTTON), + 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, + ), + ), + ), + ), +) 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..f6c34b888a --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdViewModel.kt @@ -0,0 +1,118 @@ +package com.hedvig.android.feature.onboarding.ui.petid + +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 com.hedvig.android.core.common.di.ActivityRetainedScope +import com.hedvig.android.core.common.di.HedvigViewModel +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.OnboardingProgressBarAnimation +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, + 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) } + + fun contentFrom(session: OnboardingSession): OnboardingPetIdUiState.Content { + return OnboardingPetIdUiState.Content( + progress = session.progressFor(OnboardingStepId.PetIds), + rows = session.pinnedPetIdContractIds.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 new file mode 100644 index 0000000000..4603905d35 --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneDestination.kt @@ -0,0 +1,167 @@ +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.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.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.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.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 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 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 = onClose, + progressAnimation = progressAnimation, + ) { + when (val content = uiState) { + OnboardingPhoneUiState.Loading -> { + HedvigFullScreenCenterAlignedProgressDebounced() + } + + OnboardingPhoneUiState.Error -> { + HedvigErrorSection( + onButtonClick = onRetry, + ) + } + + is OnboardingPhoneUiState.Content -> { + val phoneNumberState = rememberTextFieldState(content.phoneNumber) + LaunchedEffect(phoneNumberState) { + snapshotFlow { phoneNumberState.text.toString() } + .drop(1) + .collect { onClearSubmissionError() } + } + Spacer(Modifier.height(16.dp)) + OnboardingStepHeader( + title = stringResource(Res.string.ONBOARDING_PHONE_TITLE), + description = stringResource(Res.string.ONBOARDING_PHONE_SUBTITLE), + ) + Spacer(Modifier.weight(1f)) + Spacer(Modifier.height(24.dp)) + OnboardingPhoneKeypad(modifier = Modifier.align(Alignment.CenterHorizontally)) + Spacer(Modifier.weight(1f)) + Spacer(Modifier.height(24.dp)) + HedvigTextField( + state = phoneNumberState, + labelText = stringResource(Res.string.ONBOARDING_PHONE_TITLE), + textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, + errorState = if (content.showSubmissionError) { + HedvigTextFieldDefaults.ErrorState.Error.WithMessage( + stringResource(Res.string.ONBOARDING_PHONE_SAVE_ERROR), + ) + } else { + HedvigTextFieldDefaults.ErrorState.NoError + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + OnboardingStepButtons( + primaryText = stringResource(Res.string.general_save_button), + onPrimaryClick = { onSave(phoneNumberState.text.toString()) }, + primaryEnabled = !content.isSubmitting && phoneNumberState.text.isNotBlank(), + secondaryText = stringResource(Res.string.ONBOARDING_DO_THIS_LATER_BUTTON), + 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/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..31a9398246 --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneKeypad.kt @@ -0,0 +1,152 @@ +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 +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.delay + +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", "#"), +) + +@Composable +internal fun OnboardingPhoneKeypad(modifier: Modifier = Modifier) { + var highlightActive by remember { mutableStateOf(true) } + LaunchedEffect(Unit) { + delay(1.seconds) + highlightActive = false + } + KeypadGrid(highlightActive = highlightActive, modifier = modifier) +} + +@Composable +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 + 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 == KeypadRows[2][0]) + } + } + } + } + } +} + +@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( + modifier = Modifier + .size(KeySize) + .graphicsLayer { + scaleX = scale + scaleY = scale + } + .background(containerColor, HedvigTheme.shapes.cornerLarge), + 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(highlightActive = true, modifier = Modifier.padding(24.dp)) + } + } +} + +@HedvigPreview +@Composable +private fun PreviewOnboardingPhoneKeypadResting() { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + KeypadGrid(highlightActive = false, modifier = Modifier.padding(24.dp)) + } + } +} 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..923ee8d017 --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhoneViewModel.kt @@ -0,0 +1,139 @@ +package com.hedvig.android.feature.onboarding.ui.phone + +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 com.hedvig.android.core.common.di.ActivityRetainedScope +import com.hedvig.android.core.common.di.HedvigViewModel +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.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, + 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 new file mode 100644 index 0000000000..7ed3e79a9c --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeDestination.kt @@ -0,0 +1,275 @@ +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.getValue +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.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.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.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.theme.Theme +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 org.jetbrains.compose.resources.stringResource + +@Composable +internal fun OnboardingThemeDestination(viewModel: OnboardingThemeViewModel, navigateUp: () -> Unit) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + OnboardingThemeScreen( + 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) }, + progressAnimation = viewModel.progressBarAnimation, + ) +} + +@Composable +private fun OnboardingThemeScreen( + uiState: OnboardingThemeUiState, + navigateUp: () -> Unit, + onCloseClick: () -> Unit, + onRetry: () -> Unit, + onSelectTheme: (Theme) -> Unit, + onContinueClick: () -> Unit, + progressAnimation: OnboardingProgressBarAnimation, +) { + OnboardingStepScaffold( + progress = (uiState as? OnboardingThemeUiState.Content)?.progress, + showBackButton = true, + onBackClick = navigateUp, + onCloseClick = onCloseClick, + progressAnimation = progressAnimation, + ) { + when (uiState) { + OnboardingThemeUiState.Loading -> { + HedvigFullScreenCenterAlignedProgressDebounced() + } + + OnboardingThemeUiState.Error -> { + HedvigErrorSection( + onButtonClick = onRetry, + ) + } + + is OnboardingThemeUiState.Content -> { + Spacer(Modifier.height(16.dp)) + OnboardingStepHeader( + title = stringResource(Res.string.SETTINGS_THEME_DIALOG_TITLE), + description = stringResource(Res.string.ONBOARDING_THEME_SUBTITLE), + ) + Spacer(Modifier.weight(1f)) + Spacer(Modifier.height(24.dp)) + Column( + verticalArrangement = Arrangement.spacedBy(8.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( + text = stringResource(Res.string.ONBOARDING_CHANGE_SETTINGS_LATER_LABEL), + style = HedvigTheme.typography.label, + color = HedvigTheme.colorScheme.textSecondary, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + OnboardingStepButtons( + primaryText = stringResource(Res.string.general_continue_button), + onPrimaryClick = onContinueClick, + ) + } + } + } +} + +@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 = 12.dp, end = 16.dp, bottom = 12.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 PreviewOnboardingThemeScreen( + @PreviewParameter(OnboardingThemeUiStateProvider::class) uiState: OnboardingThemeUiState, +) { + HedvigTheme { + 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 new file mode 100644 index 0000000000..2c71d2f83e --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemeViewModel.kt @@ -0,0 +1,106 @@ +package com.hedvig.android.feature.onboarding.ui.theme + +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 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.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.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 kotlinx.coroutines.launch + +@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 +} 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..b97fd252f4 --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeDestination.kt @@ -0,0 +1,210 @@ +package com.hedvig.android.feature.onboarding.ui.welcome + +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.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.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 +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 +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) +internal class OnboardingWelcomeViewModel( + sessionStore: OnboardingSessionStore, + navigator: OnboardingNavigator, + val progressBarAnimation: OnboardingProgressBarAnimation, +) : 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() + 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 = onClose, + progressAnimation = progressAnimation, + ) { + when (uiState) { + OnboardingWelcomeUiState.Loading -> { + HedvigFullScreenCenterAlignedProgressDebounced() + } + + OnboardingWelcomeUiState.Error -> { + HedvigErrorSection( + onButtonClick = onRetry, + ) + } + + is OnboardingWelcomeUiState.Content -> { + Spacer(Modifier.weight(1f)) + Spacer(Modifier.height(24.dp)) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + OnboardingWelcomeSymbol() + Spacer(Modifier.height(24.dp)) + HedvigText( + text = stringResource(Res.string.ONBOARDING_WELCOME_TITLE), + textAlign = TextAlign.Center, + ) + HedvigText( + 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)) + Spacer(Modifier.height(24.dp)) + OnboardingStepButtons( + primaryText = stringResource(Res.string.ONBOARDING_WELCOME_BUTTON), + 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), + ), + ), +) 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..97462e896f --- /dev/null +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomeSymbol.kt @@ -0,0 +1,118 @@ +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.pillow_hedvig +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.delay +import org.jetbrains.compose.resources.painterResource + +private val SymbolSize = 128.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 + * 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.pillow_hedvig), + contentDescription = null, + modifier = Modifier.matchParentSize(), + ) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .align(Alignment.TopEnd) + .size(BadgeSize) + .graphicsLayer { + translationX = BadgeOffset.toPx() + translationY = -BadgeOffset.toPx() + scaleX = badgeScale + scaleY = badgeScale + alpha = badgeAlpha + } + .clip(CircleShape) + .background(HedvigTheme.colorScheme.signalRedElement), + ) { + HedvigText( + text = "1", + color = HedvigTheme.colorScheme.textWhite, + style = HedvigTheme.typography.bodySmall, + ) + } + } +} + +@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) + } + } +} 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..b2ff4c8b21 --- /dev/null +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/FakeOnboardingRepository.kt @@ -0,0 +1,65 @@ +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.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 + +internal class FakeOnboardingMemberIdProvider(var memberId: String? = "test-member-id") : OnboardingMemberIdProvider { + override fun memberId() = flowOf(memberId) +} + +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() + } +} + +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, + missingCoOwnersCount = 0, + isMissingPetId = false, + ), + ), + referralInformation: OnboardingReferralInformation? = OnboardingReferralInformation("CODE", 10.0, "SEK"), + payinStatus: OnboardingPayinStatus = OnboardingPayinStatus.NeedsSetup, + 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, + contracts = contracts, + referralInformation = referralInformation, + payinStatus = payinStatus, + crossSells = crossSells, +) 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..1b47636b8b --- /dev/null +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/FakeSettingsDataStore.kt @@ -0,0 +1,28 @@ +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) + val theme = MutableStateFlow(null) + + override suspend fun setAnalyticsConsent(consent: AnalyticsConsent) { + this.consent.value = consent + } + + override fun observeAnalyticsConsent(): Flow = consent + + override suspend fun setTheme(theme: Theme) { + this.theme.value = theme + } + + override fun observeTheme(): Flow = theme + + 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/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..c192c462f6 --- /dev/null +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingPathTest.kt @@ -0,0 +1,143 @@ +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.data.OnboardingPayinStatus +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, + missingCoOwnersCount: Int = 0, + isMissingPetId: Boolean = false, + ) = OnboardingContract( + id = id, + displayName = "Home Insurance", + exposureName = "Bellmansgatan 19A", + typeOfContract = typeOfContract, + missingCoInsuredCount = missingCoInsuredCount, + missingCoOwnersCount = missingCoOwnersCount, + isMissingPetId = isMissingPetId, + ) + + private fun data( + contracts: List = listOf(contract()), + referralInformation: OnboardingReferralInformation? = OnboardingReferralInformation("CODE", 10.0, "SEK"), + hasConnectedPayinMethod: Boolean = false, + 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", + contracts = contracts, + referralInformation = referralInformation, + payinStatus = if (hasConnectedPayinMethod) OnboardingPayinStatus.Active else OnboardingPayinStatus.NeedsSetup, + 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 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)))) + 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) + } + + @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/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..6e1445da73 --- /dev/null +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingSeenStoreTest.kt @@ -0,0 +1,47 @@ +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() + } + + @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-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 new file mode 100644 index 0000000000..c517c8375c --- /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.FakeOnboardingMemberIdProvider +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, FakeOnboardingMemberIdProvider()) + 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(), FakeOnboardingMemberIdProvider()), + completeOnboarding, + ) + + navigator.continueFrom(null) + + assertThat(backstack.entries).isEmpty() + assertThat(completeOnboarding.invoked).isTrue() + } +} + +private data object NonOnboardingKey : HedvigNavKey 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..0f69e9f547 --- /dev/null +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/bundle/OnboardingBundlePresenterTest.kt @@ -0,0 +1,93 @@ +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.FakeOnboardingMemberIdProvider +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, FakeOnboardingMemberIdProvider()) + 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, FakeOnboardingMemberIdProvider()) + 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 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..c05f5dbb2e --- /dev/null +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/coinsured/OnboardingCoInsuredPresenterTest.kt @@ -0,0 +1,168 @@ +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.FakeOnboardingMemberIdProvider +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, FakeOnboardingMemberIdProvider()) + 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].typeOfContract).isEqualTo("SE_APARTMENT_RENT") + 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, FakeOnboardingMemberIdProvider()) + 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", 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, + missingCoOwnersCount = 1, + isMissingPetId = false, + ), + ), + ).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)) } + 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().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, + missingCoOwnersCount = 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].typeOfContract).isEqualTo("SE_APARTMENT_RENT") + 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, FakeOnboardingMemberIdProvider()) + 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)) + } + } +} 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..4c63531b72 --- /dev/null +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/consent/OnboardingConsentPresenterTest.kt @@ -0,0 +1,78 @@ +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.FakeOnboardingMemberIdProvider +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, FakeOnboardingMemberIdProvider()) + 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, FakeOnboardingMemberIdProvider()) + 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)) + } + } +} 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..e3690a6ea7 --- /dev/null +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/invite/OnboardingInvitePresenterTest.kt @@ -0,0 +1,107 @@ +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.FakeOnboardingMemberIdProvider +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 +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 incentive`() = 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()) + val state = awaitItem() + assertThat(state).isInstanceOf() + val content = state as OnboardingInviteUiState.Content + 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)) } + 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.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, FakeOnboardingMemberIdProvider()) + 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) + } + } +} 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..e15e109852 --- /dev/null +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentPresenterTest.kt @@ -0,0 +1,156 @@ +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.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 +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 +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, 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 state = awaitItem() + assertThat(state).isInstanceOf() + val content = state as OnboardingPaymentUiState.Content + assertThat(content.payinStatus).isEqualTo(OnboardingPayinStatus.NeedsSetup) + } + } + + @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, FakeOnboardingMemberIdProvider()) + 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) + 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)) } + 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().right()) + awaitItem() + sendEvent(OnboardingPaymentEvent.Refresh) + runCurrent() + repository.onboardingDataResponses.add(testOnboardingData(payinStatus = OnboardingPayinStatus.Active).right()) + val state = awaitItem() + assertThat(state).isInstanceOf() + val content = state as OnboardingPaymentUiState.Content + 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) + } + } + + @Test + fun `continue advances to BundleDiscount`() = 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().right()) + assertThat(awaitItem()).isInstanceOf() + sendEvent(OnboardingPaymentEvent.Continue) + runCurrent() + assertThat(backstack.entries.last()).isEqualTo(OnboardingStepKey(OnboardingStepId.BundleDiscount)) + } + } +} 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..22641a72f3 --- /dev/null +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/petid/OnboardingPetIdPresenterTest.kt @@ -0,0 +1,155 @@ +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.FakeOnboardingMemberIdProvider +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, FakeOnboardingMemberIdProvider()) + 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, + missingCoOwnersCount = 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].typeOfContract).isEqualTo("SE_APARTMENT_RENT") + 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, FakeOnboardingMemberIdProvider()) + 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, + missingCoOwnersCount = 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, + missingCoOwnersCount = 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].typeOfContract).isEqualTo("SE_APARTMENT_RENT") + 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, FakeOnboardingMemberIdProvider()) + 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, + missingCoOwnersCount = 0, + isMissingPetId = true, + ), + ), + ).right(), + ) + awaitItem() + sendEvent(OnboardingPetIdEvent.Continue) + runCurrent() + assertThat(backstack.entries.last()).isEqualTo(OnboardingStepKey(OnboardingStepId.InviteFriend)) + } + } +} 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..649e8c9818 --- /dev/null +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/phone/OnboardingPhonePresenterTest.kt @@ -0,0 +1,121 @@ +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.FakeOnboardingMemberIdProvider +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, FakeOnboardingMemberIdProvider()) + 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, FakeOnboardingMemberIdProvider()) + 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("0701234567")) + runCurrent() + repository.updateContactInfoResponses.add(Unit.right()) + 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)) + } + } + + @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, FakeOnboardingMemberIdProvider()) + 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("0701234567")) + 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, FakeOnboardingMemberIdProvider()) + 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) + } + } +} 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..89a4aca39a --- /dev/null +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/theme/OnboardingThemePresenterTest.kt @@ -0,0 +1,95 @@ +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.FakeOnboardingMemberIdProvider +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, FakeOnboardingMemberIdProvider()) + 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, FakeOnboardingMemberIdProvider()) + 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, FakeOnboardingMemberIdProvider()) + 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)) + } + } +} 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..c12fc4975e --- /dev/null +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/welcome/OnboardingWelcomePresenterTest.kt @@ -0,0 +1,93 @@ +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.FakeOnboardingMemberIdProvider +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, FakeOnboardingMemberIdProvider()) + 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, FakeOnboardingMemberIdProvider()) + 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, FakeOnboardingMemberIdProvider()) + 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)) + } + } +} 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/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 5afcee731d..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 @@ -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 @@ -53,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 @@ -68,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 @@ -80,6 +84,7 @@ internal fun SettingsDestination( navigateUp: () -> Unit, openAppSettings: () -> Unit, onNavigateToDeleteAccountFeature: () -> Unit, + onNavigateToUsageData: () -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() SettingsScreen( @@ -94,6 +99,7 @@ internal fun SettingsDestination( changeSubscriptionPreference = { viewModel.emit(SettingsEvent.ChangeSubscriptionPreference(it)) }, + onNavigateToUsageData = onNavigateToUsageData, ) } @@ -103,6 +109,7 @@ private fun SettingsScreen( notificationPermissionState: NotificationPermissionState, navigateUp: () -> Unit, changeSubscriptionPreference: (Boolean) -> Unit, + onNavigateToUsageData: () -> Unit, openAppSettings: () -> Unit, onNotificationInfoDismissed: () -> Unit, onLanguageSelected: (Language) -> Unit, @@ -171,6 +178,23 @@ private fun SettingsScreen( enabled = true, hasError = uiState.emailSubscriptionPreferenceError, ) + Spacer(Modifier.height(4.dp)) + HedvigBigCard( + onClick = onNavigateToUsageData, + inputText = when (uiState.analyticsConsent) { + AnalyticsConsent.GRANTED -> stringResource(Res.string.GENERAL_ENABLED) + + 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 -> stringResource(Res.string.not_selected) + }, + labelText = stringResource(Res.string.SETTINGS_USAGE_DATA_TITLE), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) Spacer(Modifier.height(16.dp)) AnimatedVisibility( @@ -382,6 +406,7 @@ fun PreviewSettingsScreen() { showNotificationReminder = true, isSubscribedToEmails = true, emailSubscriptionPreferenceError = true, + analyticsConsent = null, ), notificationPermissionState = object : NotificationPermissionState { override val showDialog = false @@ -400,6 +425,7 @@ fun PreviewSettingsScreen() { onThemeSelected = {}, onTerminateAccountClicked = {}, changeSubscriptionPreference = {}, + 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 ba38fdd274..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 @@ -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) { @@ -83,6 +86,7 @@ internal class SettingsPresenter( showNotificationReminder = showNotificationReminder, isSubscribedToEmails = isSubscribedToEmails, emailSubscriptionPreferenceError = emailSubscriptionPreferenceError, + analyticsConsent = analyticsConsent, ) } } @@ -93,6 +97,7 @@ sealed interface SettingsUiState { val selectedTheme: Theme? val isSubscribedToEmails: Boolean? val showNotificationReminder: Boolean? + val analyticsConsent: AnalyticsConsent? val languageOptions: List get() = Language.entries @@ -102,6 +107,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 +117,7 @@ sealed interface SettingsUiState { override val showNotificationReminder: Boolean, override val isSubscribedToEmails: Boolean?, val emailSubscriptionPreferenceError: Boolean = false, + override val analyticsConsent: AnalyticsConsent?, ) : SettingsUiState } 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..f32bee3acc --- /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 hedvig.resources.SETTINGS_USAGE_DATA_TITLE +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( + topAppBarText = stringResource(Res.string.SETTINGS_USAGE_DATA_TITLE), + 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 01edd18b32..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,8 +50,10 @@ fun EntryProviderScope.profileEntries( onNavigateToTravelCertificate: () -> Unit, onNavigateToInsuranceEvidence: () -> Unit, openUrl: (String) -> Unit, + openPrivacyPolicy: () -> Unit, navigateToChipId: () -> Unit, languageService: LanguageService, + onResetOnboardingForDebug: () -> Unit, ) { entry(metadata = NavSuiteSceneDecoratorStrategy.showNavBar()) { val viewModel: ProfileViewModel = metroViewModel() @@ -112,6 +117,7 @@ fun EntryProviderScope.profileEntries( hedvigBuildConstants = hedvigBuildConstants, languageService = languageService, openUrl = openUrl, + onResetOnboardingForDebug = onResetOnboardingForDebug, ) } entry(metadata = NavSuiteSceneDecoratorStrategy.showNavBar()) { @@ -135,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() 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) 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 c62899a368..acf545f0b4 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 @@ -68,6 +68,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) { 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 a39c1367c0..3c481f7502 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,27 @@ class HedvigUnleashClient( } } + /** + * 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 + 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/FeatureUnleashKey.kt b/app/featureflags/feature-flags/src/androidMain/kotlin/com/hedvig/android/featureflags/flags/FeatureUnleashKey.kt index d0de65b5c3..355fd97c5d 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.DISABLE_TERMINATION_REDIRECTION -> "disable_termination_redirection" Feature.DISABLE_RESUMING_ONGOING_SHOP_SESSIONS -> "disable_resuming_ongoing_shop_sessions" 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..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 @@ -7,4 +7,13 @@ import kotlinx.coroutines.flow.Flow interface FeatureManager { @NativeCoroutines fun isFeatureEnabled(feature: Feature): Flow + + /** + * 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 awaitReady() } 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 e3a88691ba..b39cfb0e71 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.", ), 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 } 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..02b4069d76 --- /dev/null +++ b/app/tracking/tracking-firebase/src/main/kotlin/com/hedvig/android/tracking/firebase/ConsentAwareEventTrackingClient.kt @@ -0,0 +1,184 @@ +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. 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. + * 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 var collectionRequestedEnabled: Boolean = true + 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() + } + } + } + applyCollectionEnabled() + if (toFlush.isNotEmpty()) { + logcat { "Analytics consent granted, flushing ${toFlush.size} buffered events" } + for (call in toFlush) { + call.forwardTo(delegate) + } + } + } + } + } + + override fun setCollectionEnabled(enabled: Boolean) { + 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) { + 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 + } +} + +// Must stay public: Metro only discovers cross-module contributions with public hints (see CLAUDE.md). +@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..ce655ec5fb --- /dev/null +++ b/app/tracking/tracking-firebase/src/test/kotlin/com/hedvig/android/tracking/firebase/ConsentAwareEventTrackingClientTest.kt @@ -0,0 +1,196 @@ +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 + val collectionEnabledCalls = mutableListOf() + + override fun setCollectionEnabled(enabled: Boolean) { + collectionEnabledCalls.add(enabled) + } + + 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") + } + + @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) + } + + @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") + } +} 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..00762dcfd1 --- /dev/null +++ b/docs/superpowers/2026-07-24-onboarding-qa-checklist.md @@ -0,0 +1,109 @@ +# Onboarding manual QA checklist + +Branch: `feat/onboarding`. Check off as you go; add notes inline under any failing item. + +## Setup + +- [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` + +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 + +- [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 + +- [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 +- [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 +- [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 +- [ ] Field bottom-anchored above Save; no cursor lag while typing +- [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 +- [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 +- [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 +- [X] Centered grey cards with contract pillow images, dark pill "Add" + - 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 (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. + +### Invite a friend +- [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 +- [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) +- [X] Aborted Trustly that left a PENDING method showing "connected" is CORRECT behavior (deliberate) + +### Bundle discount +- [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. +- [X] "Continue to app" ends the flow + - Lands on Home, onboarding gone (does not return). + +## 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) + +## 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. 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. 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`. 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`. 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..967db53775 --- /dev/null +++ b/docs/superpowers/specs/2026-07-21-onboarding-design.md @@ -0,0 +1,252 @@ +# 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. + 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 + +- 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). 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. 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..6728bdbe5c --- /dev/null +++ b/docs/superpowers/specs/2026-07-29-onboarding-progress-bar-progression-design.md @@ -0,0 +1,143 @@ +# 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 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 + +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 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 holder (UI layer) + +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 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 + } + + fun setVisibleStep(key: Any, stepNumber: Int, visibleAmount: Float) { visibleSteps[key] = VisibleStep(stepNumber, visibleAmount) } + fun removeStep(key: Any) { visibleSteps.remove(key) } + + private data class VisibleStep(val stepNumber: Int, val visibleAmount: Float) + private companion object { const val MinimumVisible = 0.0001f } +} +``` + +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). + +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 + +`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. + +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 + +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 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() +} +``` + +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). + +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 (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: pass `viewModel.progressBarAnimation` into the scaffold. + +## 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: 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`. 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. 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 @@ + + +