From 4aa969f22a04ddb1d8436376add0c3ed4eb64c10 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Thu, 6 Aug 2026 15:36:19 +0200 Subject: [PATCH 1/9] docs: plan for home ongoing shop sessions feature --- .../2026-08-06-home-ongoing-shop-sessions.md | 774 ++++++++++++++++++ 1 file changed, 774 insertions(+) create mode 100644 docs/plans/2026-08-06-home-ongoing-shop-sessions.md diff --git a/docs/plans/2026-08-06-home-ongoing-shop-sessions.md b/docs/plans/2026-08-06-home-ongoing-shop-sessions.md new file mode 100644 index 0000000000..f4cc257c1b --- /dev/null +++ b/docs/plans/2026-08-06-home-ongoing-shop-sessions.md @@ -0,0 +1,774 @@ +# Home "Your quotes" from Ongoing Shop Sessions 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:** Drive the home screen's "Your quotes" section from the member's real backend `ongoingShopSessions` (resumable web shopping sessions), remove the placeholder cross-sell that used to fill it, and gate the whole section behind the `disable_resuming_ongoing_shop_sessions` kill switch. + +**Architecture:** The backend field `currentMember.ongoingShopSessions { display { ... } }` already exists in the Octopus schema (federated from `storefront` PR #1272, merged to `master`). We query it in `feature-home`, map it to a project-owned `OngoingShopSession` model, expose it on the home UI state, and render a card per session in the section previously called "Offers". The recommended cross-sell that used to fill that section is removed from it (it still feeds the cross-sell bottom sheet, unchanged). The kill switch is read in the use case: when on, we emit an empty session list, and the section (which hides when empty) disappears. + +**Tech Stack:** Kotlin, Jetpack Compose, Apollo GraphQL (Octopus), Molecule/MVI, Metro DI, Unleash feature flags, JUnit + Turbine + assertk + TestParameterInjector + Apollo test builders. + +## Global Constraints + +- **DI is Metro, not Koin.** Do not write `module { }` or `get()`. `GetHomeDataUseCaseImpl` is `@Inject`; no DI wiring changes are needed for this feature. +- **Never expose Apollo/`octopus.*` types in public API.** Map query results to project-owned types inside the `internal` use-case impl only. `OngoingShopSession` is the project-owned type. +- **Strings are managed by Lokalise.** Never add to any `strings.xml`. For new UI text, hardcode the English string and add a `// TODO: Add "" / "" to Lokalise` comment. Reuse existing keys where they already exist (the section title `HOME_QUOTES_SECTION_TITLE` already exists). +- **Feature-flag polarity + defaults.** `disable_resuming_ongoing_shop_sessions` is a kill switch. The enum name mirrors the key (`DISABLE_...`); `UnleashFeatureFlagProvider` returns the raw `isEnabled(key)`; the consumer inverts. Never-fetched default is `false` (switch off → feature available), which is acceptable here (not app-gating, and the section is empty until data loads anyway) — so **no bootstrap entry**. See `app/featureflags/feature-flags/FEATURE_FLAG_DEFAULTS.md`. +- **Logging** goes through `logcat` only. +- **Formatting:** run `./gradlew ktlintFormat` before each commit. +- **`navigateUp` rule** is irrelevant here (no new navigation). The resume button opens a web URL via the existing `openUrl` lambda. + +--- + +## File Structure + +- `app/featureflags/feature-flags/src/commonMain/.../flags/Feature.kt` — add the enum value. +- `app/featureflags/feature-flags/src/androidMain/.../flags/FeatureUnleashKey.kt` — add the raw key mapping (exhaustive `when`, compiler-enforced). +- `app/feature/feature-home/src/main/graphql/QueryHome.graphql` — add the `ongoingShopSessions` selection. +- `app/feature/feature-home/build.gradle.kts` — add `implementation(projects.coreUiData)` (needed for `UiMoney`). +- `app/feature/feature-home/src/main/kotlin/.../home/data/GetHomeDataUseCase.kt` — new `OngoingShopSession` model, `HomeData.ongoingShopSessions` field, mapping, and kill-switch gating. +- `app/feature/feature-home/src/main/kotlin/.../home/data/GetHomeDataUseCaseDemo.kt` — sample session data for demo mode. +- `app/feature/feature-home/src/main/kotlin/.../home/ui/HomePresenter.kt` — expose `ongoingShopSessions` on `HomeUiState.Success`; later remove the now-dead `offersCrossSell`. +- `app/feature/feature-home/src/main/kotlin/.../home/ui/HomeDestination.kt` — render the section from sessions; rename `Offers` → `Quotes`; update the preview. +- `app/feature/feature-home/src/test/kotlin/.../home/data/GetHomeUseCaseTest.kt` — new mapping + gating tests; patch existing map-based `FakeFeatureManager` sites. +- `app/feature/feature-home/src/test/kotlin/.../home/ui/HomePresenterTest.kt` — new propagation test; update the 3 `offersCrossSell` references. + +**Gradle project paths** (modules are named by leaf directory): `:feature-home`, `:feature-flags`, `:core-ui-data`. +**Common commands:** unit tests `./gradlew :feature-home:testDebugUnitTest`; single test `--tests "com.hedvig.android.feature.home.home.data.GetHomeUseCaseTest"`; apollo codegen `./gradlew :feature-home:generateApolloSources`; format `./gradlew ktlintFormat`. + +--- + +## Task 1: Add the `DISABLE_RESUMING_ONGOING_SHOP_SESSIONS` feature flag + +**Files:** +- Modify: `app/featureflags/feature-flags/src/commonMain/kotlin/com/hedvig/android/featureflags/flags/Feature.kt` +- Modify: `app/featureflags/feature-flags/src/androidMain/kotlin/com/hedvig/android/featureflags/flags/FeatureUnleashKey.kt` + +**Interfaces:** +- Produces: `Feature.DISABLE_RESUMING_ONGOING_SHOP_SESSIONS` (enum value) and its Unleash key `"disable_resuming_ongoing_shop_sessions"`, consumed by Task 3. + +**Notes:** There is no dedicated unit test for the enum→key mapping; the `when` in `FeatureUnleashKey.kt` is exhaustive with no `else`, so a missing mapping is a **compile error**. Behavioral verification of the flag happens in Task 3's gating test. This task's deliverable is verified by compilation. + +- [ ] **Step 1: Add the enum value** + +In `Feature.kt`, add a new entry to the `enum class Feature` (place it next to the other `DISABLE_*` kill switches): + +```kotlin + DISABLE_RESUMING_ONGOING_SHOP_SESSIONS( + "Kill switch for the home screen 'Your quotes' section, which lets a member resume an ongoing " + + "shopping session they started on the web. When the toggle is on, the section is hidden.", + ), +``` + +- [ ] **Step 2: Add the Unleash key mapping** + +In `FeatureUnleashKey.kt`, add a branch to the `when` (keep alphabetical/grouped with the other `DISABLE_*` entries): + +```kotlin + Feature.DISABLE_RESUMING_ONGOING_SHOP_SESSIONS -> "disable_resuming_ongoing_shop_sessions" +``` + +- [ ] **Step 3: Verify it compiles** + +Run: `./gradlew :feature-flags:compileDebugKotlinAndroid` +Expected: BUILD SUCCESSFUL (an exhaustive-`when` error here would mean the mapping is missing). + +- [ ] **Step 4: Format and commit** + +```bash +./gradlew ktlintFormat +git add app/featureflags/feature-flags/src +git commit -m "featureflags: add disable_resuming_ongoing_shop_sessions kill switch" +``` + +--- + +## Task 2: Query `ongoingShopSessions` in the Home query + +**Files:** +- Modify: `app/feature/feature-home/src/main/graphql/QueryHome.graphql` + +**Interfaces:** +- Produces: `HomeQuery.Data.CurrentMember.ongoingShopSessions`, each with `.id` and `.display` (`.display.title`, `.display.subtitle`, `.display.monthlyNet?.{amount,currencyCode}`, `.display.resumeUrl`, `.display.pillowImage?.src`). Consumed by Task 3. + +**Notes:** No new query variable is added, so the generated `HomeQuery(...)` constructor arity is unchanged and existing tests that call `HomeQuery(true, false)` / `HomeQuery(true, true)` keep compiling. Gating is done in the use case (Task 3), not via an `@include` variable, to keep the blast radius small. + +- [ ] **Step 1: Add the selection** + +In `QueryHome.graphql`, inside the `currentMember { ... }` block (e.g. right after the `crossSellV2(...) { ... }` block), add: + +```graphql + ongoingShopSessions { + id + display { + title + subtitle + monthlyNet { + amount + currencyCode + } + resumeUrl + pillowImage { + src + } + } + } +``` + +- [ ] **Step 2: Regenerate Apollo sources and verify** + +Run: `./gradlew :feature-home:generateApolloSources` +Expected: BUILD SUCCESSFUL, and `HomeQuery.Data.CurrentMember.OngoingShopSession` (with a nested `Display`) is generated. If the build complains the field is unknown, the local Octopus schema is stale — run `./gradlew downloadOctopusApolloSchemaFromIntrospection` and retry. + +- [ ] **Step 3: Commit** + +```bash +git add app/feature/feature-home/src/main/graphql/QueryHome.graphql +git commit -m "feature-home: query currentMember.ongoingShopSessions" +``` + +--- + +## Task 3: Map sessions into `HomeData`, gated by the kill switch + +**Files:** +- Modify: `app/feature/feature-home/build.gradle.kts` +- Modify: `app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/data/GetHomeDataUseCase.kt` +- Test: `app/feature/feature-home/src/test/kotlin/com/hedvig/android/feature/home/home/data/GetHomeUseCaseTest.kt` + +**Interfaces:** +- Consumes: `HomeQuery.Data.CurrentMember.ongoingShopSessions` (Task 2); `Feature.DISABLE_RESUMING_ONGOING_SHOP_SESSIONS` (Task 1); `com.hedvig.android.core.uidata.UiMoney` + `com.hedvig.android.core.uidata.UiCurrencyCode`. +- Produces: + ```kotlin + data class OngoingShopSession( + val id: String, + val title: String, + val subtitle: String?, + val monthlyNet: UiMoney?, + val resumeUrl: String, + val pillowImageUrl: String?, + ) + ``` + and `HomeData.ongoingShopSessions: List` (default `emptyList()`), consumed by Tasks 4/5/7. + +- [ ] **Step 1: Add the `core-ui-data` dependency** + +In `app/feature/feature-home/build.gradle.kts`, add to the `dependencies { }` block (alphabetically near other `projects.core*` entries): + +```kotlin + implementation(projects.coreUiData) +``` + +- [ ] **Step 2: Write the failing mapping test** + +In `GetHomeUseCaseTest.kt`, add the imports at the top: + +```kotlin +import com.hedvig.android.core.uidata.UiCurrencyCode +import com.hedvig.android.feature.home.home.data.HomeData +import octopus.type.CurrencyCode +import octopus.type.buildMoney +import octopus.type.buildShopSession +import octopus.type.buildShopSessionDisplay +import assertk.assertions.containsExactly +``` +(Skip any import already present — `HomeData` and `containsExactly` are likely already imported.) + +Then add this test (uses `FakeFeatureManager(mapOf(...))`, so it must list every flag the use case reads — including the new one, set to `false` = not disabled): + +```kotlin + @Test + fun `ongoing shop sessions are mapped into HomeData when the kill switch is off`() = runTest { + val featureManager = FakeFeatureManager( + mapOf( + Feature.ENABLE_NEW_CONVERSATION_FROM_INBOX to false, + Feature.ENABLE_CLAIM_INTENT_RESUME to false, + Feature.DISABLE_RESUMING_ONGOING_SHOP_SESSIONS to false, + ), + ) + val getHomeDataUseCase = testUseCaseWithoutReminders(featureManager) + + apolloClient.registerTestResponse( + HomeQuery(true, false), + HomeQuery.Data(OctopusFakeResolver) { + currentMember = buildMember { + ongoingShopSessions = listOf( + buildShopSession { + id = "session-1" + display = buildShopSessionDisplay { + title = "Home + Accident" + subtitle = "Studio apartment, Stockholm" + monthlyNet = buildMoney { + amount = 199.0 + currencyCode = CurrencyCode.SEK + } + resumeUrl = "https://hedvig.com/resume/session-1" + pillowImage = null + } + }, + ) + } + }, + ) + apolloClient.registerTestResponse(UnreadMessageCountQuery(), UnreadMessageCountQuery.Data(OctopusFakeResolver)) + + val result = getHomeDataUseCase.invoke(true).first() + + assertThat(result) + .isNotNull() + .isRight() + .prop(HomeData::ongoingShopSessions) + .containsExactly( + OngoingShopSession( + id = "session-1", + title = "Home + Accident", + subtitle = "Studio apartment, Stockholm", + monthlyNet = UiMoney(199.0, UiCurrencyCode.SEK), + resumeUrl = "https://hedvig.com/resume/session-1", + pillowImageUrl = null, + ), + ) + } +``` + +Also add the import for the model: +```kotlin +import com.hedvig.android.feature.home.home.data.OngoingShopSession +``` + +- [ ] **Step 3: Run it and confirm it fails to compile / fails** + +Run: `./gradlew :feature-home:testDebugUnitTest --tests "com.hedvig.android.feature.home.home.data.GetHomeUseCaseTest"` +Expected: FAIL — `OngoingShopSession` and `HomeData.ongoingShopSessions` don't exist yet (compile error), or the assertion fails. + +- [ ] **Step 4: Add the `OngoingShopSession` model and the `HomeData` field** + +In `GetHomeDataUseCase.kt`, add the model near the other data-layer models (e.g. just below the `HomeData` data class, or beside `RecommendedCrossSell` usages): + +```kotlin +data class OngoingShopSession( + val id: String, + val title: String, + val subtitle: String?, + val monthlyNet: UiMoney?, + val resumeUrl: String, + val pillowImageUrl: String?, +) +``` + +Add the field to `data class HomeData(...)` with a default so existing construction sites (tests, demo) stay terse: + +```kotlin + val ongoingShopSessions: List = emptyList(), +``` + +Add imports to the file: +```kotlin +import com.hedvig.android.core.uidata.UiCurrencyCode +import com.hedvig.android.core.uidata.UiMoney +``` + +- [ ] **Step 5: Read the kill switch and map the sessions** + +In `GetHomeDataUseCaseImpl.invoke(...)`, change the outer flag read so both flags are read, then gate the mapping. + +Replace the current outer: +```kotlin + return featureManager.isFeatureEnabled(Feature.ENABLE_CLAIM_INTENT_RESUME) + .flatMapLatest { resumeClaimEnabled -> + combine( +``` +with: +```kotlin + return combine( + featureManager.isFeatureEnabled(Feature.ENABLE_CLAIM_INTENT_RESUME), + featureManager.isFeatureEnabled(Feature.DISABLE_RESUMING_ONGOING_SHOP_SESSIONS), + ::Pair, + ).flatMapLatest { (resumeClaimEnabled, disableShopSessions) -> + combine( +``` +(The inner `combine(...)` over the 6 data flows is unchanged — do not add a 7th flow to it.) + +Inside the `either { }` block (next to where `crossSellsData` / `recommendedCrossSell` are built), add: +```kotlin + val ongoingShopSessions = if (disableShopSessions) { + emptyList() + } else { + homeQueryData.currentMember.ongoingShopSessions.map { session -> + OngoingShopSession( + id = session.id, + title = session.display.title, + subtitle = session.display.subtitle, + monthlyNet = session.display.monthlyNet?.let { + UiMoney(it.amount, UiCurrencyCode.fromCurrencyCode(it.currencyCode)) + }, + resumeUrl = session.display.resumeUrl, + pillowImageUrl = session.display.pillowImage?.src, + ) + } + } +``` + +Pass it into the `HomeData(...)` constructor at the end of the `either { }` block: +```kotlin + ongoingShopSessions = ongoingShopSessions, +``` + +- [ ] **Step 6: Add the gating (kill-switch-on) test** + +In `GetHomeUseCaseTest.kt`, add: + +```kotlin + @Test + fun `ongoing shop sessions are dropped when the kill switch is on`() = runTest { + val featureManager = FakeFeatureManager( + mapOf( + Feature.ENABLE_NEW_CONVERSATION_FROM_INBOX to false, + Feature.ENABLE_CLAIM_INTENT_RESUME to false, + Feature.DISABLE_RESUMING_ONGOING_SHOP_SESSIONS to true, + ), + ) + val getHomeDataUseCase = testUseCaseWithoutReminders(featureManager) + + apolloClient.registerTestResponse( + HomeQuery(true, false), + HomeQuery.Data(OctopusFakeResolver) { + currentMember = buildMember { + ongoingShopSessions = listOf( + buildShopSession { + id = "session-1" + display = buildShopSessionDisplay { + title = "Home + Accident" + resumeUrl = "https://hedvig.com/resume/session-1" + } + }, + ) + } + }, + ) + apolloClient.registerTestResponse(UnreadMessageCountQuery(), UnreadMessageCountQuery.Data(OctopusFakeResolver)) + + val result = getHomeDataUseCase.invoke(true).first() + + assertThat(result) + .isNotNull() + .isRight() + .prop(HomeData::ongoingShopSessions) + .isEmpty() + } +``` + +- [ ] **Step 7: Patch existing map-based `FakeFeatureManager` sites** + +Because the use case now reads a second flag, any test that builds `FakeFeatureManager(mapOf(...))` must include the new flag, or `isFeatureEnabled` will block on the turbine and the test will hang. Add the entry `Feature.DISABLE_RESUMING_ONGOING_SHOP_SESSIONS to false,` to **each** of the 4 map literals in `GetHomeUseCaseTest.kt`. They are the blocks starting near lines **457, 530, 590, 749**, each currently containing: +```kotlin + Feature.ENABLE_NEW_CONVERSATION_FROM_INBOX to ..., + Feature.ENABLE_CLAIM_INTENT_RESUME to false, +``` +Add the new line right after the `ENABLE_CLAIM_INTENT_RESUME` entry in all four: +```kotlin + Feature.DISABLE_RESUMING_ONGOING_SHOP_SESSIONS to false, +``` +The `FakeFeatureManager(true)` sites (all-true) need no change — they already resolve every flag. + +- [ ] **Step 8: Run the module's use-case tests and confirm green** + +Run: `./gradlew :feature-home:testDebugUnitTest --tests "com.hedvig.android.feature.home.home.data.GetHomeUseCaseTest"` +Expected: PASS (both new tests pass; no existing test hangs or fails). + +- [ ] **Step 9: Format and commit** + +```bash +./gradlew ktlintFormat +git add app/feature/feature-home/build.gradle.kts app/feature/feature-home/src/main app/feature/feature-home/src/test +git commit -m "feature-home: map ongoing shop sessions into HomeData behind the kill switch" +``` + +--- + +## Task 4: Expose `ongoingShopSessions` on the home UI state + +**Files:** +- Modify: `app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomePresenter.kt` +- Test: `app/feature/feature-home/src/test/kotlin/com/hedvig/android/feature/home/home/ui/HomePresenterTest.kt` + +**Interfaces:** +- Consumes: `HomeData.ongoingShopSessions` (Task 3). +- Produces: `HomeUiState.Success.ongoingShopSessions: List` (default `emptyList()`), consumed by Task 5. + +- [ ] **Step 1: Write the failing propagation test** + +In `HomePresenterTest.kt`, add a test that pushes a `HomeData` carrying one session and asserts it reaches `HomeUiState.Success`. Model the presenter construction on the existing tests (7 constructor args). Add imports: +```kotlin +import com.hedvig.android.core.uidata.UiMoney +import com.hedvig.android.core.uidata.UiCurrencyCode +import com.hedvig.android.feature.home.home.data.OngoingShopSession +import assertk.assertions.containsExactly +``` +Test: +```kotlin + @Test + fun `ongoing shop sessions propagate to the success ui state`() = runTest { + val getHomeDataUseCase = TestGetHomeDataUseCase() + val homePresenter = HomePresenter( + getHomeDataUseCase, + SeenImportantMessagesStorageImpl(), + FakeCrossSellHomeNotificationService(), + ApplicationScope(backgroundScope), + false, + TestDeleteClaimIntentDraftUseCase(), + FakeGetMemberQuickActionsUseCase(emptyList().right()), + ) + + val session = OngoingShopSession( + id = "session-1", + title = "Home + Accident", + subtitle = null, + monthlyNet = UiMoney(199.0, UiCurrencyCode.SEK), + resumeUrl = "https://hedvig.com/resume/session-1", + pillowImageUrl = null, + ) + + homePresenter.test(HomeUiState.Loading) { + assertThat(awaitItem()).isEqualTo(HomeUiState.Loading) + getHomeDataUseCase.responseTurbine.add( + someIrrelevantHomeDataInstance.copy(ongoingShopSessions = listOf(session)).right(), + ) + val success = awaitItem() + assertThat(success) + .isInstanceOf() + .prop(HomeUiState.Success::ongoingShopSessions) + .containsExactly(session) + } + } +``` +If `someIrrelevantHomeDataInstance` is not defined in this file, build a minimal `HomeData(...)` inline instead (see the existing `a successful response, properly propagates the info to the UI State` test for the field list) and set `ongoingShopSessions = listOf(session)`. + +- [ ] **Step 2: Run it and confirm it fails** + +Run: `./gradlew :feature-home:testDebugUnitTest --tests "com.hedvig.android.feature.home.home.ui.HomePresenterTest"` +Expected: FAIL — `HomeUiState.Success.ongoingShopSessions` does not exist yet. + +- [ ] **Step 3: Add the field to `HomeUiState.Success`** + +In `HomePresenter.kt`, in `data class Success(...)`, add (near `crossSellsPartition`): +```kotlin + val ongoingShopSessions: List = emptyList(), +``` +Add the import: +```kotlin +import com.hedvig.android.feature.home.home.data.OngoingShopSession +``` + +- [ ] **Step 4: Thread it through `SuccessData`** + +In `private data class SuccessData(...)`, add: +```kotlin + val ongoingShopSessions: List, +``` +In `SuccessData.fromLastState(...)`, add: +```kotlin + ongoingShopSessions = lastState.ongoingShopSessions, +``` +In `SuccessData.fromHomeData(...)` return, add: +```kotlin + ongoingShopSessions = homeData.ongoingShopSessions, +``` +In the `HomeUiState.Success(...)` construction (where `crossSellsPartition = successData.crossSellsPartition` is set), add: +```kotlin + ongoingShopSessions = successData.ongoingShopSessions, +``` + +- [ ] **Step 5: Run the presenter tests and confirm green** + +Run: `./gradlew :feature-home:testDebugUnitTest --tests "com.hedvig.android.feature.home.home.ui.HomePresenterTest"` +Expected: PASS. + +- [ ] **Step 6: Format and commit** + +```bash +./gradlew ktlintFormat +git add app/feature/feature-home/src +git commit -m "feature-home: expose ongoingShopSessions on the home ui state" +``` + +--- + +## Task 5: Render the "Your quotes" section from sessions + +**Files:** +- Modify: `app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomeDestination.kt` + +**Interfaces:** +- Consumes: `HomeUiState.Success.ongoingShopSessions` (Task 4); the existing `openUrl: (String) -> Unit` param already threaded into `HomeDestination` (opens a web URL — the correct handler for `resumeUrl`, which needs no auth). +- Produces: a `QuotesSection` composable; the `HomeSection.Quotes` entry. + +**Notes:** This task switches what the section *renders* but does not yet remove the now-unused `crossSellsPartition.offersCrossSell` field — that cleanup is Task 6, kept separate so it can be reviewed independently. The section is a pure UI render of state; there is no presenter/unit test for Compose here (consistent with the module — the section's data is covered by Tasks 3–4). Verification is compilation + the `@Preview`. + +- [ ] **Step 1: Rename the section enum `Offers` → `Quotes`** + +In `HomeDestination.kt`, in `private enum class HomeSection`, rename `Offers` to `Quotes`. Update the reference in `homeSectionOrder` (`HomeSection.Offers` → `HomeSection.Quotes`). + +- [ ] **Step 2: Point the visibility guard at the sessions** + +Find the `HomeSection.Offers -> { uiState.crossSellsPartition.offersCrossSell != null }` branch (currently ~line 664) and change it to: +```kotlin + HomeSection.Quotes -> { + uiState.ongoingShopSessions.isNotEmpty() + } +``` + +- [ ] **Step 3: Point the render branch at the sessions** + +Find the `HomeSection.Offers -> uiState.crossSellsPartition.offersCrossSell?.let { ... OffersSection(...) }` branch (currently ~line 891) and replace it with: +```kotlin + HomeSection.Quotes -> uiState.ongoingShopSessions.takeIf { it.isNotEmpty() }?.let { sessions -> + QuotesSection( + sessions = sessions, + onResumeClick = openUrl, + imageLoader = imageLoader, + horizontalInsets = horizontalInsets, + ) + } +``` + +- [ ] **Step 4: Replace `OffersSection` with `QuotesSection`** + +Replace the entire `private fun OffersSection(...)` composable (currently ~lines 1101–1171) with: +```kotlin +@Composable +private fun QuotesSection( + sessions: List, + onResumeClick: (String) -> Unit, + imageLoader: ImageLoader, + horizontalInsets: PaddingValues, +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(horizontalInsets), + ) { + HedvigText( + text = stringResource(Res.string.HOME_QUOTES_SECTION_TITLE), + style = HedvigTheme.typography.headlineSmall, + modifier = Modifier.semantics { heading() }, + ) + for (session in sessions) { + HedvigCard( + onClick = { onResumeClick(session.resumeUrl) }, + color = HedvigTheme.colorScheme.fillNegative, + borderColor = HedvigTheme.colorScheme.borderPrimary, + modifier = Modifier + .fillMaxWidth() + .hedvigDropShadow(HedvigTheme.shapes.cornerXLarge), + ) { + Column(Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (session.pillowImageUrl != null) { + AsyncImage( + model = session.pillowImageUrl, + contentDescription = null, + imageLoader = imageLoader, + contentScale = ContentScale.Fit, + modifier = Modifier.size(48.dp), + ) + Spacer(Modifier.width(12.dp)) + } + Column(Modifier.weight(1f)) { + HedvigText(text = session.title, style = HedvigTheme.typography.bodySmall) + val secondary = session.subtitle ?: session.monthlyNet?.toString() + if (secondary != null) { + HedvigText( + text = secondary, + style = HedvigTheme.typography.label, + color = HedvigTheme.colorScheme.textSecondary, + ) + } + } + } + Spacer(Modifier.height(12.dp)) + // TODO: Add "Resume" / "Fortsätt" to Lokalise + HedvigButton( + text = "Resume", + onClick = { onResumeClick(session.resumeUrl) }, + buttonStyle = Secondary, + buttonSize = ButtonSize.Medium, + enabled = true, + shape = HedvigTheme.shapes.cornerFull, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } +} +``` +Add the import for the model: +```kotlin +import com.hedvig.android.feature.home.home.data.OngoingShopSession +``` +(All other symbols — `HedvigCard`, `HedvigButton`, `AsyncImage`, `Secondary`, `ButtonSize`, `hedvigDropShadow`, etc. — are already imported for the old `OffersSection`.) + +- [ ] **Step 5: Update the `@Preview` to exercise the new section** + +In the preview's `HomeUiState.Success(...)` (the block currently containing `crossSellsPartition = CrossSellsPartition(offersCrossSell = RecommendedCrossSell(...), discoverCrossSells = emptyList())`, ~line 1479), add a sessions argument so the section renders in the preview: +```kotlin + ongoingShopSessions = listOf( + OngoingShopSession( + id = "preview-1", + title = "Home + Accident", + subtitle = "Studio apartment, Stockholm", + monthlyNet = null, + resumeUrl = "", + pillowImageUrl = null, + ), + ), +``` +Leave the `crossSellsPartition = CrossSellsPartition(offersCrossSell = ...)` argument as-is for now (it is removed in Task 6). + +- [ ] **Step 6: Verify it compiles** + +Run: `./gradlew :feature-home:compileDebugKotlin` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 7: Format and commit** + +```bash +./gradlew ktlintFormat +git add app/feature/feature-home/src/main +git commit -m "feature-home: render the Your quotes section from ongoing shop sessions" +``` + +--- + +## Task 6: Remove the placeholder cross-sell from the quotes section + +**Files:** +- Modify: `app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomePresenter.kt` +- Modify: `app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomeDestination.kt` +- Test: `app/feature/feature-home/src/test/kotlin/com/hedvig/android/feature/home/home/ui/HomePresenterTest.kt` + +**Interfaces:** +- Removes `CrossSellsPartition.offersCrossSell`. `CrossSellsPartition` keeps only `discoverCrossSells: List`, still consumed by the `DiscoverInsurances` section. The recommended cross-sell remains in `CrossSellSheetData` and still feeds the cross-sell bottom sheet (`crossSellsAction`) — do not touch that. + +**Notes:** This is the "commit to approach #1" cleanup. It is separate from Task 5 so a reviewer can accept the new UI first. + +- [ ] **Step 1: Drop the field from `CrossSellsPartition` and update its doc** + +In `HomePresenter.kt`, remove `val offersCrossSell: RecommendedCrossSell? = null,` from `data class CrossSellsPartition(...)`, leaving: +```kotlin +internal data class CrossSellsPartition( + val discoverCrossSells: List = emptyList(), +) +``` +Update the KDoc above it so it describes the current reality (it currently mentions "Offers"): +```kotlin +/** + * The home screen surfaces cross-sells in the discover carousel and the "Discover our insurances" + * list. This is the single place that decides which cross-sells go where. + */ +``` +In `partitionCrossSells(...)`, remove the `offersCrossSell = crossSells.recommendedCrossSell,` line and the `// WS0 placeholder ...` comment, leaving: +```kotlin +internal fun partitionCrossSells(crossSells: CrossSellSheetData): CrossSellsPartition { + return CrossSellsPartition( + discoverCrossSells = crossSells.otherCrossSells, + ) +} +``` +If the `RecommendedCrossSell` import becomes unused in this file after this, remove it. (It is still used via `homeData.crossSells.recommendedCrossSell` for `crossSellsAction`, so it likely stays — let the compiler/ktlint tell you.) + +- [ ] **Step 2: Remove the dead preview argument** + +In `HomeDestination.kt`, in the preview's `CrossSellsPartition(...)`, remove the `offersCrossSell = RecommendedCrossSell(...)` argument (the whole `RecommendedCrossSell(...)` block), leaving: +```kotlin + crossSellsPartition = CrossSellsPartition( + discoverCrossSells = emptyList(), + ), +``` + +- [ ] **Step 3: Update the 3 presenter-test references** + +In `HomePresenterTest.kt`, remove the `offersCrossSell = testCrossSell` usages: +- Line ~220: `crossSellsPartition = CrossSellsPartition(offersCrossSell = testCrossSell),` → `crossSellsPartition = CrossSellsPartition(),` +- Lines ~561 and ~704: remove the `offersCrossSell = testCrossSell,` line from each `CrossSellsPartition(...)` literal (keep the `discoverCrossSells = ...` argument if present; if `offersCrossSell` was the only argument, leave `CrossSellsPartition()`). + +Read each site before editing to preserve the surrounding `discoverCrossSells` argument. + +- [ ] **Step 4: Compile and run the full module test suite** + +Run: `./gradlew :feature-home:testDebugUnitTest` +Expected: BUILD SUCCESSFUL, all tests PASS. (Confirms nothing else read `offersCrossSell`.) + +- [ ] **Step 5: Format and commit** + +```bash +./gradlew ktlintFormat +git add app/feature/feature-home/src +git commit -m "feature-home: drop the placeholder cross-sell from the quotes section" +``` + +--- + +## Task 7: Show sessions in demo mode + +**Files:** +- Modify: `app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/data/GetHomeDataUseCaseDemo.kt` + +**Interfaces:** +- Consumes: `HomeData.ongoingShopSessions` + `OngoingShopSession` (Task 3). + +**Notes:** Demo mode never reads feature flags, so the section shows whenever `ongoingShopSessions` is non-empty. Keep the existing `crossSells` block as-is (it still drives the cross-sell sheet in demo). + +- [ ] **Step 1: Add sample session data** + +In `GetHomeDataUseCaseDemo.kt`, add to the `HomeData(...)` constructor: +```kotlin + ongoingShopSessions = listOf( + OngoingShopSession( + id = "demo-session-1", + title = "Home + Accident", + subtitle = "Studio apartment, Stockholm", + monthlyNet = UiMoney(199.0, UiCurrencyCode.SEK), + resumeUrl = "https://www.hedvig.com", + pillowImageUrl = null, + ), + ), +``` +Add imports: +```kotlin +import com.hedvig.android.core.uidata.UiCurrencyCode +import com.hedvig.android.core.uidata.UiMoney +``` + +- [ ] **Step 2: Compile the module** + +Run: `./gradlew :feature-home:compileDebugKotlin` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 3: Format and commit** + +```bash +./gradlew ktlintFormat +git add app/feature/feature-home/src/main +git commit -m "feature-home: show a sample ongoing shop session in demo mode" +``` + +--- + +## Final verification + +- [ ] Run the full module test suite: `./gradlew :feature-home:testDebugUnitTest` → all PASS. +- [ ] Confirm formatting: `./gradlew ktlintCheck` → no violations. +- [ ] (Optional, manual) Run the app in demo mode and confirm the "Your quotes" section shows the sample session with a working "Resume" button; toggle `disable_resuming_ongoing_shop_sessions` on in a real build and confirm the section disappears. + +## Notes / decisions baked into this plan + +- **Approach #1 (chosen):** the recommended cross-sell no longer appears in the home "Your quotes" section. It is *not* deleted from the data layer — it still feeds the cross-sell bottom sheet via `CrossSellSheetData.recommendedCrossSell`, untouched. +- **Kill-switch gating is in the use case** (drop to empty list when on), not via a GraphQL `@include` variable, to avoid changing the generated `HomeQuery` constructor arity and breaking every existing test call site. Cost: the field is fetched even when the switch is on (the rare/rollback case); this is negligible. +- **Resume URL** is opened with the existing `openUrl` lambda (already threaded into `HomeDestination`), which matches the backend contract ("open directly in the device browser, no auth required"). No new `:app` plumbing. +- **No bootstrap** for the flag: natural never-fetched default (section available, but empty until data loads) is safe and correct. +- **`monthlyGross`, `lastActivityAt`, `validTo`** are available on the backend `display` type but are not queried in v1 (YAGNI). Add them if design wants a struck-through price, a relative "2 days ago" label, or client-side expiry hiding. +- **"Resume" button copy** is hardcoded with a Lokalise TODO. If a suitable generic key already exists in `strings.xml` (e.g. a "Continue" string), prefer reusing it over the hardcode. From 0842838c0ccdbc556e347ddceb702052ad8dcfb8 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Thu, 6 Aug 2026 15:59:39 +0200 Subject: [PATCH 2/9] featureflags: add disable_resuming_ongoing_shop_sessions kill switch --- .../hedvig/android/featureflags/flags/FeatureUnleashKey.kt | 1 + .../kotlin/com/hedvig/android/featureflags/flags/Feature.kt | 4 ++++ 2 files changed, 5 insertions(+) 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 17671551c1..d0de65b5c3 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 @@ -4,6 +4,7 @@ internal val Feature.unleashKey: String get() = when (this) { 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" Feature.ENABLE_CLAIM_INTENT_RESUME -> "enable_claim_intent_resume" Feature.ENABLE_NEW_CONVERSATION_FROM_INBOX -> "enable_new_conversation_from_inbox" Feature.UPDATE_NECESSARY -> "update_necessary" 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 eedb74609d..e3a88691ba 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 @@ -12,6 +12,10 @@ enum class Feature( "survey without redirections (sends redirectionEnabled = false), so the moving option keeps its plain " + "sub options instead of the redirection interstitial.", ), + DISABLE_RESUMING_ONGOING_SHOP_SESSIONS( + "Kill switch for the home screen 'Your quotes' section, which lets a member resume an ongoing " + + "shopping session they started on the web. When the toggle is on, the section is hidden.", + ), ENABLE_CLAIM_INTENT_RESUME( "Enables resuming a draft claim: the draft card on the home screen, the draft-claim dialogs, " + "and the resumable-aware leave dialog in the claim chat.", From 4b140b5aa111a27c226811795152429d068c027b Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Thu, 6 Aug 2026 15:59:39 +0200 Subject: [PATCH 3/9] feature-home: query currentMember.ongoingShopSessions --- .../src/main/graphql/QueryHome.graphql | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/feature/feature-home/src/main/graphql/QueryHome.graphql b/app/feature/feature-home/src/main/graphql/QueryHome.graphql index 0d29fa7dd7..4b94f35902 100644 --- a/app/feature/feature-home/src/main/graphql/QueryHome.graphql +++ b/app/feature/feature-home/src/main/graphql/QueryHome.graphql @@ -80,6 +80,21 @@ query Home($claimsHistoryFlag: Boolean!, $resumeClaimEnabled: Boolean!) { } } } + ongoingShopSessions { + id + display { + title + subtitle + monthlyNet { + amount + currencyCode + } + resumeUrl + pillowImage { + src + } + } + } memberActions { firstVetAction { sections { From e43ac127e6d539a4691b35a8192978d782206760 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Thu, 6 Aug 2026 15:59:39 +0200 Subject: [PATCH 4/9] feature-home: map ongoing shop sessions into HomeData behind the kill switch --- app/feature/feature-home/build.gradle.kts | 1 + .../home/home/data/GetHomeDataUseCase.kt | 38 +++++- .../home/home/data/GetHomeDataUseCaseDemo.kt | 12 ++ .../home/home/data/GetHomeUseCaseTest.kt | 109 ++++++++++++++++++ 4 files changed, 158 insertions(+), 2 deletions(-) diff --git a/app/feature/feature-home/build.gradle.kts b/app/feature/feature-home/build.gradle.kts index 83bb01edf9..8798902f73 100644 --- a/app/feature/feature-home/build.gradle.kts +++ b/app/feature/feature-home/build.gradle.kts @@ -37,6 +37,7 @@ dependencies { implementation(projects.coreDemoMode) implementation(projects.coreMarkdown) implementation(projects.coreResources) + implementation(projects.coreUiData) implementation(projects.crossSells) implementation(projects.dataAddons) implementation(projects.dataClaimIntent) diff --git a/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/data/GetHomeDataUseCase.kt b/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/data/GetHomeDataUseCase.kt index e884924629..48351c1588 100644 --- a/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/data/GetHomeDataUseCase.kt +++ b/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/data/GetHomeDataUseCase.kt @@ -12,6 +12,8 @@ import com.apollographql.apollo.cache.normalized.FetchPolicy import com.apollographql.apollo.cache.normalized.fetchPolicy import com.hedvig.android.apollo.ApolloOperationError import com.hedvig.android.apollo.safeFlow +import com.hedvig.android.core.uidata.UiCurrencyCode +import com.hedvig.android.core.uidata.UiMoney import com.hedvig.android.crosssells.BundleProgress import com.hedvig.android.crosssells.CrossSellSheetData import com.hedvig.android.crosssells.RecommendedAddon @@ -67,8 +69,12 @@ internal class GetHomeDataUseCaseImpl( ) : GetHomeDataUseCase { @OptIn(ExperimentalCoroutinesApi::class) override fun invoke(forceNetworkFetch: Boolean): Flow> { - return featureManager.isFeatureEnabled(Feature.ENABLE_CLAIM_INTENT_RESUME) - .flatMapLatest { resumeClaimEnabled -> + return combine( + featureManager.isFeatureEnabled(Feature.ENABLE_CLAIM_INTENT_RESUME), + featureManager.isFeatureEnabled(Feature.DISABLE_RESUMING_ONGOING_SHOP_SESSIONS), + ::Pair, + ) + .flatMapLatest { (resumeClaimEnabled, disableShopSessions) -> combine( apolloClient.query(HomeQuery(true, resumeClaimEnabled)) .fetchPolicy(if (forceNetworkFetch) FetchPolicy.NetworkOnly else FetchPolicy.CacheAndNetwork) @@ -148,6 +154,22 @@ internal class GetHomeDataUseCaseImpl( val otherCrossSellsData = crossSellsData.otherCrossSells.map { it.toCrossSell() } + val ongoingShopSessions = if (disableShopSessions) { + emptyList() + } else { + homeQueryData.currentMember.ongoingShopSessions.map { session -> + OngoingShopSession( + id = session.id, + title = session.display.title, + subtitle = session.display.subtitle, + monthlyNet = session.display.monthlyNet?.let { + UiMoney(it.amount, UiCurrencyCode.fromCurrencyCode(it.currencyCode)) + }, + resumeUrl = session.display.resumeUrl, + pillowImageUrl = session.display.pillowImage?.src, + ) + } + } val recommendedAddon = crossSellsData.recommendedAddon?.let { RecommendedAddon( id = it.id, @@ -202,6 +224,7 @@ internal class GetHomeDataUseCaseImpl( showHelpCenter = true, firstVetSections = firstVetActions, crossSells = crossSells, + ongoingShopSessions = ongoingShopSessions, addonBannerInfos = travelBannerInfo.orEmpty(), showChatIcon = showChatIcon, firstName = homeQueryData.currentMember.firstName, @@ -310,6 +333,15 @@ private fun HomeQuery.Data.claimStatusCards(): HomeData.ClaimStatusCardsData? { ) } +data class OngoingShopSession( + val id: String, + val title: String, + val subtitle: String?, + val monthlyNet: UiMoney?, + val resumeUrl: String, + val pillowImageUrl: String?, +) + data class HomeData( val contractStatus: ContractStatus, val claimStatusCardsData: ClaimStatusCardsData?, @@ -321,6 +353,8 @@ data class HomeData( val firstVetSections: List, val crossSells: CrossSellSheetData, val addonBannerInfos: List, + // Defaulted only so test/demo construction sites stay terse. + val ongoingShopSessions: List = emptyList(), // Always populated from the backend; defaulted only so test/demo construction sites stay terse. val firstName: String = "", val draftClaim: DraftClaim?, diff --git a/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/data/GetHomeDataUseCaseDemo.kt b/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/data/GetHomeDataUseCaseDemo.kt index c445223692..989070d798 100644 --- a/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/data/GetHomeDataUseCaseDemo.kt +++ b/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/data/GetHomeDataUseCaseDemo.kt @@ -3,6 +3,8 @@ package com.hedvig.android.feature.home.home.data import arrow.core.Either import arrow.core.right import com.hedvig.android.apollo.ApolloOperationError +import com.hedvig.android.core.uidata.UiCurrencyCode +import com.hedvig.android.core.uidata.UiMoney import com.hedvig.android.crosssells.CrossSellSheetData import com.hedvig.android.crosssells.RecommendedCrossSell import com.hedvig.android.data.contract.CrossSell @@ -55,6 +57,16 @@ internal class GetHomeDataUseCaseDemo : GetHomeDataUseCase { ), recommendedAddon = null, ), + ongoingShopSessions = listOf( + OngoingShopSession( + id = "demo-session-1", + title = "Home + Accident", + subtitle = "Studio apartment, Stockholm", + monthlyNet = UiMoney(199.0, UiCurrencyCode.SEK), + resumeUrl = "https://www.hedvig.com", + pillowImageUrl = null, + ), + ), addonBannerInfos = emptyList(), showChatIcon = false, firstName = "Demo", diff --git a/app/feature/feature-home/src/test/kotlin/com/hedvig/android/feature/home/home/data/GetHomeUseCaseTest.kt b/app/feature/feature-home/src/test/kotlin/com/hedvig/android/feature/home/home/data/GetHomeUseCaseTest.kt index 8059b063d3..3664bf6427 100644 --- a/app/feature/feature-home/src/test/kotlin/com/hedvig/android/feature/home/home/data/GetHomeUseCaseTest.kt +++ b/app/feature/feature-home/src/test/kotlin/com/hedvig/android/feature/home/home/data/GetHomeUseCaseTest.kt @@ -26,6 +26,8 @@ import com.hedvig.android.apollo.test.TestApolloClientRule import com.hedvig.android.apollo.test.TestNetworkTransportType import com.hedvig.android.core.common.ErrorMessage import com.hedvig.android.core.common.test.isRight +import com.hedvig.android.core.uidata.UiCurrencyCode +import com.hedvig.android.core.uidata.UiMoney import com.hedvig.android.crosssells.CrossSellSheetData import com.hedvig.android.crosssells.RecommendedAddon import com.hedvig.android.data.addons.data.AddonBannerInfo @@ -54,6 +56,7 @@ import octopus.CbmNumberOfChatMessagesQuery import octopus.HomeQuery import octopus.UnreadMessageCountQuery import octopus.type.ChatMessageSender +import octopus.type.CurrencyCode import octopus.type.buildChatMessagePage import octopus.type.buildChatMessageText import octopus.type.buildClaim @@ -63,8 +66,11 @@ import octopus.type.buildCrossSellV2 import octopus.type.buildLinkInfo import octopus.type.buildMember import octopus.type.buildMemberImportantMessage +import octopus.type.buildMoney import octopus.type.buildPendingContract import octopus.type.buildRecommendedAddonCrossSell +import octopus.type.buildShopSession +import octopus.type.buildShopSessionDisplay import octopus.type.buildStoryblokImageAsset import org.junit.Rule import org.junit.Test @@ -459,6 +465,7 @@ internal class GetHomeUseCaseTest { // With the inbox-always-available kill switch off, the icon depends purely on existing conversations Feature.ENABLE_NEW_CONVERSATION_FROM_INBOX to false, Feature.ENABLE_CLAIM_INTENT_RESUME to false, + Feature.DISABLE_RESUMING_ONGOING_SHOP_SESSIONS to false, ), ) val getHomeDataUseCase = testUseCaseWithoutReminders(featureManager) @@ -531,6 +538,7 @@ internal class GetHomeUseCaseTest { mapOf( Feature.ENABLE_NEW_CONVERSATION_FROM_INBOX to inboxAlwaysAvailable, Feature.ENABLE_CLAIM_INTENT_RESUME to false, + Feature.DISABLE_RESUMING_ONGOING_SHOP_SESSIONS to false, ), ) val getHomeDataUseCase = testUseCaseWithoutReminders(featureManager) @@ -592,6 +600,7 @@ internal class GetHomeUseCaseTest { // Inbox-always-available off, so the icon reflects the conversation state being tested here Feature.ENABLE_NEW_CONVERSATION_FROM_INBOX to false, Feature.ENABLE_CLAIM_INTENT_RESUME to false, + Feature.DISABLE_RESUMING_ONGOING_SHOP_SESSIONS to false, ), ) val getHomeDataUseCase = testUseCaseWithoutReminders(featureManager) @@ -750,6 +759,7 @@ internal class GetHomeUseCaseTest { mapOf( Feature.ENABLE_NEW_CONVERSATION_FROM_INBOX to false, Feature.ENABLE_CLAIM_INTENT_RESUME to false, + Feature.DISABLE_RESUMING_ONGOING_SHOP_SESSIONS to false, ), ) val getHomeDataUseCase = testUseCaseWithoutReminders(featureManager) @@ -770,6 +780,105 @@ internal class GetHomeUseCaseTest { homeData.prop(HomeData::showChatIcon).isFalse() } + @Test + fun `ongoing shop sessions are mapped into HomeData when the kill switch is off`() = runTest { + val featureManager = FakeFeatureManager( + mapOf( + Feature.ENABLE_NEW_CONVERSATION_FROM_INBOX to false, + Feature.ENABLE_CLAIM_INTENT_RESUME to false, + Feature.DISABLE_RESUMING_ONGOING_SHOP_SESSIONS to false, + ), + ) + val getHomeDataUseCase = testUseCaseWithoutReminders(featureManager) + + apolloClient.registerTestResponse( + HomeQuery(true, false), + HomeQuery.Data(OctopusFakeResolver) { + currentMember = buildMember { + ongoingShopSessions = listOf( + buildShopSession { + id = "session-1" + display = buildShopSessionDisplay { + title = "Home + Accident" + subtitle = "Studio apartment, Stockholm" + monthlyNet = buildMoney { + amount = 199.0 + currencyCode = CurrencyCode.SEK + } + resumeUrl = "https://hedvig.com/resume/session-1" + pillowImage = null + } + }, + ) + } + }, + ) + apolloClient.registerTestResponse(UnreadMessageCountQuery(), UnreadMessageCountQuery.Data(OctopusFakeResolver)) + apolloClient.registerTestResponse( + CbmNumberOfChatMessagesQuery(), + CbmNumberOfChatMessagesQuery.Data(OctopusFakeResolver), + ) + + val result = getHomeDataUseCase.invoke(true).first() + + assertThat(result) + .isNotNull() + .isRight() + .prop(HomeData::ongoingShopSessions) + .containsExactly( + OngoingShopSession( + id = "session-1", + title = "Home + Accident", + subtitle = "Studio apartment, Stockholm", + monthlyNet = UiMoney(199.0, UiCurrencyCode.SEK), + resumeUrl = "https://hedvig.com/resume/session-1", + pillowImageUrl = null, + ), + ) + } + + @Test + fun `ongoing shop sessions are dropped when the kill switch is on`() = runTest { + val featureManager = FakeFeatureManager( + mapOf( + Feature.ENABLE_NEW_CONVERSATION_FROM_INBOX to false, + Feature.ENABLE_CLAIM_INTENT_RESUME to false, + Feature.DISABLE_RESUMING_ONGOING_SHOP_SESSIONS to true, + ), + ) + val getHomeDataUseCase = testUseCaseWithoutReminders(featureManager) + + apolloClient.registerTestResponse( + HomeQuery(true, false), + HomeQuery.Data(OctopusFakeResolver) { + currentMember = buildMember { + ongoingShopSessions = listOf( + buildShopSession { + id = "session-1" + display = buildShopSessionDisplay { + title = "Home + Accident" + resumeUrl = "https://hedvig.com/resume/session-1" + } + }, + ) + } + }, + ) + apolloClient.registerTestResponse(UnreadMessageCountQuery(), UnreadMessageCountQuery.Data(OctopusFakeResolver)) + apolloClient.registerTestResponse( + CbmNumberOfChatMessagesQuery(), + CbmNumberOfChatMessagesQuery.Data(OctopusFakeResolver), + ) + + val result = getHomeDataUseCase.invoke(true).first() + + assertThat(result) + .isNotNull() + .isRight() + .prop(HomeData::ongoingShopSessions) + .isEmpty() + } + // Used as a convenience to get a use case without any enqueued apollo responses, but some sane defaults for the // other dependencies private fun testUseCaseWithoutReminders( From 0c72d2d4d2231234f6253d751d14b4dfd731d89f Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Thu, 6 Aug 2026 15:59:39 +0200 Subject: [PATCH 5/9] feature-home: render the Your quotes section from ongoing shop sessions --- .../feature/home/home/ui/HomeDestination.kt | 136 +++++++++--------- .../feature/home/home/ui/HomePresenter.kt | 15 +- .../feature/home/home/ui/HomePresenterTest.kt | 43 +++++- 3 files changed, 113 insertions(+), 81 deletions(-) diff --git a/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomeDestination.kt b/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomeDestination.kt index 50a27a9cf7..99bd6a79c8 100644 --- a/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomeDestination.kt +++ b/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomeDestination.kt @@ -145,6 +145,7 @@ import com.hedvig.android.feature.home.home.data.HomeData.ClaimStatusCardsData import com.hedvig.android.feature.home.home.data.HomeData.DraftClaim import com.hedvig.android.feature.home.home.data.HomeData.VeryImportantMessage import com.hedvig.android.feature.home.home.data.HomeData.VeryImportantMessage.LinkInfo +import com.hedvig.android.feature.home.home.data.OngoingShopSession import com.hedvig.android.feature.home.home.ui.HomeText.Active import com.hedvig.android.feature.home.home.ui.HomeTopBarAction.ChatAction import com.hedvig.android.feature.home.home.ui.HomeTopBarAction.CrossSellsAction @@ -660,8 +661,8 @@ private fun HomeScreenSuccess( applicableReminders.homeActionRequiredReminders().isNotEmpty() } - HomeSection.Offers -> { - uiState.crossSellsPartition.offersCrossSell != null + HomeSection.Quotes -> { + uiState.ongoingShopSessions.isNotEmpty() } HomeSection.DiscoverInsurances -> { @@ -888,10 +889,10 @@ private fun HomeScreenSuccess( horizontalInsets = horizontalInsets, ) - HomeSection.Offers -> uiState.crossSellsPartition.offersCrossSell?.let { recommended -> - OffersSection( - recommendedCrossSell = recommended, - onCrossSellClick = openCrossSellUrl, + HomeSection.Quotes -> uiState.ongoingShopSessions.takeIf { it.isNotEmpty() }?.let { sessions -> + QuotesSection( + sessions = sessions, + onResumeClick = openUrl, imageLoader = imageLoader, horizontalInsets = horizontalInsets, ) @@ -971,7 +972,7 @@ private enum class HomeSection { ClaimStatusCards, VeryImportantMessages, MemberReminders, - Offers, + Quotes, DiscoverInsurances, Addons, QuickActionTiles, @@ -984,7 +985,7 @@ private val homeSectionOrder: List = listOf( HomeSection.ClaimStatusCards, HomeSection.VeryImportantMessages, HomeSection.MemberReminders, - HomeSection.Offers, + HomeSection.Quotes, HomeSection.QuickActionTiles, HomeSection.DiscoverInsurances, HomeSection.Addons, @@ -1099,13 +1100,12 @@ private fun MemberRemindersSection( } @Composable -private fun OffersSection( - recommendedCrossSell: RecommendedCrossSell, - onCrossSellClick: (String) -> Unit, +private fun QuotesSection( + sessions: List, + onResumeClick: (String) -> Unit, imageLoader: ImageLoader, horizontalInsets: PaddingValues, ) { - val crossSell = recommendedCrossSell.crossSell Column( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier @@ -1118,52 +1118,51 @@ private fun OffersSection( style = HedvigTheme.typography.headlineSmall, modifier = Modifier.semantics { heading() }, ) - HedvigCard( - onClick = { onCrossSellClick(crossSell.storeUrl) }, - color = HedvigTheme.colorScheme.fillNegative, - borderColor = HedvigTheme.colorScheme.borderPrimary, - modifier = Modifier - .fillMaxWidth() - .hedvigDropShadow(HedvigTheme.shapes.cornerXLarge), - ) { - Column(Modifier.padding(16.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - AsyncImage( - model = crossSell.pillowImage.src, - contentDescription = null, - imageLoader = imageLoader, - contentScale = ContentScale.Fit, - modifier = Modifier.size(48.dp), - ) - Spacer(Modifier.width(12.dp)) - Column(Modifier.weight(1f)) { - HedvigText(text = crossSell.title, style = HedvigTheme.typography.bodySmall) - HedvigText( - text = recommendedCrossSell.bannerText, - style = HedvigTheme.typography.label, - color = HedvigTheme.colorScheme.textSecondary, - ) - } - val discountText = recommendedCrossSell.discountText - if (discountText != null) { - Spacer(Modifier.width(8.dp)) - HighlightLabel( - labelText = discountText, - size = HighlightLabelDefaults.HighLightSize.Small, - color = HighlightLabelDefaults.HighlightColor.Green(HighlightLabelDefaults.HighlightShade.LIGHT), - ) + for (session in sessions) { + HedvigCard( + onClick = { onResumeClick(session.resumeUrl) }, + color = HedvigTheme.colorScheme.fillNegative, + borderColor = HedvigTheme.colorScheme.borderPrimary, + modifier = Modifier + .fillMaxWidth() + .hedvigDropShadow(HedvigTheme.shapes.cornerXLarge), + ) { + Column(Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (session.pillowImageUrl != null) { + AsyncImage( + model = session.pillowImageUrl, + contentDescription = null, + imageLoader = imageLoader, + contentScale = ContentScale.Fit, + modifier = Modifier.size(48.dp), + ) + Spacer(Modifier.width(12.dp)) + } + Column(Modifier.weight(1f)) { + HedvigText(text = session.title, style = HedvigTheme.typography.bodySmall) + val secondary = session.subtitle ?: session.monthlyNet?.toString() + if (secondary != null) { + HedvigText( + text = secondary, + style = HedvigTheme.typography.label, + color = HedvigTheme.colorScheme.textSecondary, + ) + } + } } + Spacer(Modifier.height(12.dp)) + // TODO: Add "Resume" / "Fortsätt" to Lokalise + HedvigButton( + text = "Resume", + onClick = { onResumeClick(session.resumeUrl) }, + buttonStyle = Secondary, + buttonSize = ButtonSize.Medium, + enabled = true, + shape = HedvigTheme.shapes.cornerFull, + modifier = Modifier.fillMaxWidth(), + ) } - Spacer(Modifier.height(12.dp)) - HedvigButton( - text = recommendedCrossSell.buttonText, - onClick = { onCrossSellClick(crossSell.storeUrl) }, - buttonStyle = Secondary, - buttonSize = ButtonSize.Medium, - enabled = true, - shape = HedvigTheme.shapes.cornerFull, - modifier = Modifier.fillMaxWidth(), - ) } } } @@ -1477,23 +1476,18 @@ private fun PreviewHomeScreen( quickActions = previewQuickActions, hasUnseenChatMessages = hasUnseenChatMessages, crossSellsPartition = CrossSellsPartition( - offersCrossSell = RecommendedCrossSell( - crossSell = CrossSell( - id = "car", - title = "Car Insurance", - subtitle = "For you and your car", - storeUrl = "", - pillowImage = ImageAsset("", "", ""), - ), - bannerText = "15% bundle discount", - buttonText = "See your price", - discountText = "-15%", - buttonDescription = "", - backgroundPillowImages = "" to "", - bundleProgress = null, - ), discoverCrossSells = emptyList(), ), + ongoingShopSessions = listOf( + OngoingShopSession( + id = "preview-1", + title = "Home + Accident", + subtitle = "Studio apartment, Stockholm", + monthlyNet = null, + resumeUrl = "", + pillowImageUrl = null, + ), + ), crossSellsAction = CrossSellsAction( CrossSellSheetData( recommendedCrossSell = RecommendedCrossSell( diff --git a/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomePresenter.kt b/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomePresenter.kt index d6c6a746e6..fae748a582 100644 --- a/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomePresenter.kt +++ b/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomePresenter.kt @@ -14,12 +14,12 @@ import arrow.core.getOrElse import com.hedvig.android.apollo.ApolloOperationError import com.hedvig.android.core.common.ApplicationScope import com.hedvig.android.crosssells.CrossSellSheetData -import com.hedvig.android.crosssells.RecommendedCrossSell import com.hedvig.android.data.addons.data.AddonBannerInfo import com.hedvig.android.data.claimintent.DeleteClaimIntentDraftUseCase import com.hedvig.android.data.contract.CrossSell import com.hedvig.android.feature.home.home.data.GetHomeDataUseCase import com.hedvig.android.feature.home.home.data.HomeData +import com.hedvig.android.feature.home.home.data.OngoingShopSession import com.hedvig.android.feature.home.home.data.SeenImportantMessagesStorage import com.hedvig.android.logger.LogPriority import com.hedvig.android.logger.logcat @@ -164,6 +164,7 @@ internal class HomePresenter( addonBannerInfos = successData.addonBannerInfos, isProduction = isProduction, crossSellsPartition = successData.crossSellsPartition, + ongoingShopSessions = successData.ongoingShopSessions, firstName = successData.firstName, draftClaim = successData.draftClaim, ) @@ -211,6 +212,7 @@ internal sealed interface HomeUiState { val quickActions: List, override val hasUnseenChatMessages: Boolean, val crossSellsPartition: CrossSellsPartition = CrossSellsPartition(), + val ongoingShopSessions: List = emptyList(), val firstName: String = "", val draftClaim: HomeData.DraftClaim?, ) : HomeUiState @@ -233,6 +235,7 @@ private data class SuccessData( val hasUnseenChatMessages: Boolean, val addonBannerInfos: List, val crossSellsPartition: CrossSellsPartition, + val ongoingShopSessions: List, val firstName: String, val draftClaim: HomeData.DraftClaim?, ) { @@ -252,6 +255,7 @@ private data class SuccessData( addonBannerInfos = lastState.addonBannerInfos, chatAction = lastState.chatAction, crossSellsPartition = lastState.crossSellsPartition, + ongoingShopSessions = lastState.ongoingShopSessions, firstName = lastState.firstName, draftClaim = lastState.draftClaim, ) @@ -305,6 +309,7 @@ private data class SuccessData( addonBannerInfos = homeData.addonBannerInfos, chatAction = if (homeData.showChatIcon) HomeTopBarAction.ChatAction else null, crossSellsPartition = partitionCrossSells(homeData.crossSells), + ongoingShopSessions = homeData.ongoingShopSessions, firstName = homeData.firstName, draftClaim = homeData.draftClaim, ) @@ -313,19 +318,15 @@ private data class SuccessData( } /** - * The home screen surfaces cross-sells in three places (Offers, the discover carousel and the - * "Discover our insurances" list). This is the single place that decides which cross-sells go where. + * The home screen surfaces cross-sells in the discover carousel and the "Discover our insurances" + * list. This is the single place that decides which cross-sells go where. */ internal data class CrossSellsPartition( - val offersCrossSell: RecommendedCrossSell? = null, val discoverCrossSells: List = emptyList(), ) -// WS0 placeholder rule until design finalizes per-section cross-sell assignment: Offers shows the -// recommended cross-sell; both the carousel and the "Discover our insurances" list show the others. internal fun partitionCrossSells(crossSells: CrossSellSheetData): CrossSellsPartition { return CrossSellsPartition( - offersCrossSell = crossSells.recommendedCrossSell, discoverCrossSells = crossSells.otherCrossSells, ) } diff --git a/app/feature/feature-home/src/test/kotlin/com/hedvig/android/feature/home/home/ui/HomePresenterTest.kt b/app/feature/feature-home/src/test/kotlin/com/hedvig/android/feature/home/home/ui/HomePresenterTest.kt index 65b91b7e58..c489d57c10 100644 --- a/app/feature/feature-home/src/test/kotlin/com/hedvig/android/feature/home/home/ui/HomePresenterTest.kt +++ b/app/feature/feature-home/src/test/kotlin/com/hedvig/android/feature/home/home/ui/HomePresenterTest.kt @@ -6,6 +6,7 @@ import arrow.core.left import arrow.core.nonEmptyListOf import arrow.core.right import assertk.assertThat +import assertk.assertions.containsExactly import assertk.assertions.isEqualTo import assertk.assertions.isFalse import assertk.assertions.isInstanceOf @@ -16,6 +17,8 @@ import com.google.testing.junit.testparameterinjector.TestParameterInjector import com.hedvig.android.apollo.ApolloOperationError import com.hedvig.android.core.common.ApplicationScope import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.core.uidata.UiCurrencyCode +import com.hedvig.android.core.uidata.UiMoney import com.hedvig.android.crosssells.CrossSellSheetData import com.hedvig.android.crosssells.RecommendedAddon import com.hedvig.android.crosssells.RecommendedCrossSell @@ -24,6 +27,7 @@ import com.hedvig.android.data.contract.CrossSell import com.hedvig.android.data.contract.ImageAsset import com.hedvig.android.feature.home.home.data.GetHomeDataUseCase import com.hedvig.android.feature.home.home.data.HomeData +import com.hedvig.android.feature.home.home.data.OngoingShopSession import com.hedvig.android.feature.home.home.data.SeenImportantMessagesStorageImpl import com.hedvig.android.logger.TestLogcatLoggingRule import com.hedvig.android.memberquickactions.GetMemberQuickActionsUseCase @@ -217,7 +221,7 @@ internal class HomePresenterTest { hasUnseenChatMessages = false, addonBannerInfos = emptyList(), isProduction = false, - crossSellsPartition = CrossSellsPartition(offersCrossSell = testCrossSell), + crossSellsPartition = CrossSellsPartition(), draftClaim = null, ), ) @@ -558,7 +562,6 @@ internal class HomePresenterTest { addonBannerInfos = emptyList(), isProduction = false, crossSellsPartition = CrossSellsPartition( - offersCrossSell = testCrossSell, discoverCrossSells = listOf(crossSell), ), draftClaim = null, @@ -701,7 +704,6 @@ internal class HomePresenterTest { .prop(HomeUiState.Success::crossSellsPartition) .isEqualTo( CrossSellsPartition( - offersCrossSell = testCrossSell, discoverCrossSells = listOf(otherCrossSell), ), ) @@ -865,6 +867,41 @@ internal class HomePresenterTest { ), ) + @Test + fun `ongoing shop sessions propagate to the success ui state`() = runTest { + val getHomeDataUseCase = TestGetHomeDataUseCase() + val homePresenter = HomePresenter( + getHomeDataUseCase, + SeenImportantMessagesStorageImpl(), + FakeCrossSellHomeNotificationService(), + ApplicationScope(backgroundScope), + false, + TestDeleteClaimIntentDraftUseCase(), + FakeGetMemberQuickActionsUseCase(emptyList().right()), + ) + + val session = OngoingShopSession( + id = "session-1", + title = "Home + Accident", + subtitle = null, + monthlyNet = UiMoney(199.0, UiCurrencyCode.SEK), + resumeUrl = "https://hedvig.com/resume/session-1", + pillowImageUrl = null, + ) + + homePresenter.test(HomeUiState.Loading) { + assertThat(awaitItem()).isEqualTo(HomeUiState.Loading) + getHomeDataUseCase.responseTurbine.add( + someIrrelevantHomeDataInstance.copy(ongoingShopSessions = listOf(session)).right(), + ) + val success = awaitItem() + assertThat(success) + .isInstanceOf() + .prop(HomeUiState.Success::ongoingShopSessions) + .containsExactly(session) + } + } + private val someIrrelevantHomeDataInstance: HomeData = HomeData( contractStatus = HomeData.ContractStatus.Active, claimStatusCardsData = null, From 29e74edee14454fdf31eeb0554b66b2436734086 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Thu, 6 Aug 2026 16:22:12 +0200 Subject: [PATCH 6/9] featureflags: bootstrap disable_resuming_ongoing_shop_sessions hidden until first fetch --- .../com/hedvig/android/featureflags/HedvigUnleashClient.kt | 1 + 1 file changed, 1 insertion(+) 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 e86fd86169..a39c1367c0 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 @@ -75,6 +75,7 @@ class HedvigUnleashClient( bootstrap = listOf( Toggle(name = Feature.DISABLE_PUPPY_GUIDE.unleashKey, enabled = true), Toggle(name = Feature.DISABLE_TERMINATION_REDIRECTION.unleashKey, enabled = true), + Toggle(name = Feature.DISABLE_RESUMING_ONGOING_SHOP_SESSIONS.unleashKey, enabled = true), ), ) } From 887f6832bf7de1ba320a4cfc3e71f78ddecd9cfe Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Thu, 6 Aug 2026 16:22:12 +0200 Subject: [PATCH 7/9] feature-home: fix shop-session test builders after octopus.builder package move --- .../android/feature/home/home/data/GetHomeUseCaseTest.kt | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/app/feature/feature-home/src/test/kotlin/com/hedvig/android/feature/home/home/data/GetHomeUseCaseTest.kt b/app/feature/feature-home/src/test/kotlin/com/hedvig/android/feature/home/home/data/GetHomeUseCaseTest.kt index 22dd8ebc86..1ff82688c1 100644 --- a/app/feature/feature-home/src/test/kotlin/com/hedvig/android/feature/home/home/data/GetHomeUseCaseTest.kt +++ b/app/feature/feature-home/src/test/kotlin/com/hedvig/android/feature/home/home/data/GetHomeUseCaseTest.kt @@ -65,15 +65,14 @@ import octopus.builder.buildCrossSellV2 import octopus.builder.buildLinkInfo import octopus.builder.buildMember import octopus.builder.buildMemberImportantMessage +import octopus.builder.buildMoney import octopus.builder.buildPendingContract import octopus.builder.buildRecommendedAddonCrossSell +import octopus.builder.buildShopSession +import octopus.builder.buildShopSessionDisplay import octopus.builder.buildStoryblokImageAsset import octopus.type.ChatMessageSender import octopus.type.CurrencyCode -import octopus.type.buildMember -import octopus.type.buildMoney -import octopus.type.buildShopSession -import octopus.type.buildShopSessionDisplay import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith From 0f3b72717eb62b8d8bb366f9be6d16d62034fdc8 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Thu, 6 Aug 2026 16:52:16 +0200 Subject: [PATCH 8/9] Remove plan --- .../2026-08-06-home-ongoing-shop-sessions.md | 774 ------------------ 1 file changed, 774 deletions(-) delete mode 100644 docs/plans/2026-08-06-home-ongoing-shop-sessions.md diff --git a/docs/plans/2026-08-06-home-ongoing-shop-sessions.md b/docs/plans/2026-08-06-home-ongoing-shop-sessions.md deleted file mode 100644 index f4cc257c1b..0000000000 --- a/docs/plans/2026-08-06-home-ongoing-shop-sessions.md +++ /dev/null @@ -1,774 +0,0 @@ -# Home "Your quotes" from Ongoing Shop Sessions 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:** Drive the home screen's "Your quotes" section from the member's real backend `ongoingShopSessions` (resumable web shopping sessions), remove the placeholder cross-sell that used to fill it, and gate the whole section behind the `disable_resuming_ongoing_shop_sessions` kill switch. - -**Architecture:** The backend field `currentMember.ongoingShopSessions { display { ... } }` already exists in the Octopus schema (federated from `storefront` PR #1272, merged to `master`). We query it in `feature-home`, map it to a project-owned `OngoingShopSession` model, expose it on the home UI state, and render a card per session in the section previously called "Offers". The recommended cross-sell that used to fill that section is removed from it (it still feeds the cross-sell bottom sheet, unchanged). The kill switch is read in the use case: when on, we emit an empty session list, and the section (which hides when empty) disappears. - -**Tech Stack:** Kotlin, Jetpack Compose, Apollo GraphQL (Octopus), Molecule/MVI, Metro DI, Unleash feature flags, JUnit + Turbine + assertk + TestParameterInjector + Apollo test builders. - -## Global Constraints - -- **DI is Metro, not Koin.** Do not write `module { }` or `get()`. `GetHomeDataUseCaseImpl` is `@Inject`; no DI wiring changes are needed for this feature. -- **Never expose Apollo/`octopus.*` types in public API.** Map query results to project-owned types inside the `internal` use-case impl only. `OngoingShopSession` is the project-owned type. -- **Strings are managed by Lokalise.** Never add to any `strings.xml`. For new UI text, hardcode the English string and add a `// TODO: Add "" / "" to Lokalise` comment. Reuse existing keys where they already exist (the section title `HOME_QUOTES_SECTION_TITLE` already exists). -- **Feature-flag polarity + defaults.** `disable_resuming_ongoing_shop_sessions` is a kill switch. The enum name mirrors the key (`DISABLE_...`); `UnleashFeatureFlagProvider` returns the raw `isEnabled(key)`; the consumer inverts. Never-fetched default is `false` (switch off → feature available), which is acceptable here (not app-gating, and the section is empty until data loads anyway) — so **no bootstrap entry**. See `app/featureflags/feature-flags/FEATURE_FLAG_DEFAULTS.md`. -- **Logging** goes through `logcat` only. -- **Formatting:** run `./gradlew ktlintFormat` before each commit. -- **`navigateUp` rule** is irrelevant here (no new navigation). The resume button opens a web URL via the existing `openUrl` lambda. - ---- - -## File Structure - -- `app/featureflags/feature-flags/src/commonMain/.../flags/Feature.kt` — add the enum value. -- `app/featureflags/feature-flags/src/androidMain/.../flags/FeatureUnleashKey.kt` — add the raw key mapping (exhaustive `when`, compiler-enforced). -- `app/feature/feature-home/src/main/graphql/QueryHome.graphql` — add the `ongoingShopSessions` selection. -- `app/feature/feature-home/build.gradle.kts` — add `implementation(projects.coreUiData)` (needed for `UiMoney`). -- `app/feature/feature-home/src/main/kotlin/.../home/data/GetHomeDataUseCase.kt` — new `OngoingShopSession` model, `HomeData.ongoingShopSessions` field, mapping, and kill-switch gating. -- `app/feature/feature-home/src/main/kotlin/.../home/data/GetHomeDataUseCaseDemo.kt` — sample session data for demo mode. -- `app/feature/feature-home/src/main/kotlin/.../home/ui/HomePresenter.kt` — expose `ongoingShopSessions` on `HomeUiState.Success`; later remove the now-dead `offersCrossSell`. -- `app/feature/feature-home/src/main/kotlin/.../home/ui/HomeDestination.kt` — render the section from sessions; rename `Offers` → `Quotes`; update the preview. -- `app/feature/feature-home/src/test/kotlin/.../home/data/GetHomeUseCaseTest.kt` — new mapping + gating tests; patch existing map-based `FakeFeatureManager` sites. -- `app/feature/feature-home/src/test/kotlin/.../home/ui/HomePresenterTest.kt` — new propagation test; update the 3 `offersCrossSell` references. - -**Gradle project paths** (modules are named by leaf directory): `:feature-home`, `:feature-flags`, `:core-ui-data`. -**Common commands:** unit tests `./gradlew :feature-home:testDebugUnitTest`; single test `--tests "com.hedvig.android.feature.home.home.data.GetHomeUseCaseTest"`; apollo codegen `./gradlew :feature-home:generateApolloSources`; format `./gradlew ktlintFormat`. - ---- - -## Task 1: Add the `DISABLE_RESUMING_ONGOING_SHOP_SESSIONS` feature flag - -**Files:** -- Modify: `app/featureflags/feature-flags/src/commonMain/kotlin/com/hedvig/android/featureflags/flags/Feature.kt` -- Modify: `app/featureflags/feature-flags/src/androidMain/kotlin/com/hedvig/android/featureflags/flags/FeatureUnleashKey.kt` - -**Interfaces:** -- Produces: `Feature.DISABLE_RESUMING_ONGOING_SHOP_SESSIONS` (enum value) and its Unleash key `"disable_resuming_ongoing_shop_sessions"`, consumed by Task 3. - -**Notes:** There is no dedicated unit test for the enum→key mapping; the `when` in `FeatureUnleashKey.kt` is exhaustive with no `else`, so a missing mapping is a **compile error**. Behavioral verification of the flag happens in Task 3's gating test. This task's deliverable is verified by compilation. - -- [ ] **Step 1: Add the enum value** - -In `Feature.kt`, add a new entry to the `enum class Feature` (place it next to the other `DISABLE_*` kill switches): - -```kotlin - DISABLE_RESUMING_ONGOING_SHOP_SESSIONS( - "Kill switch for the home screen 'Your quotes' section, which lets a member resume an ongoing " + - "shopping session they started on the web. When the toggle is on, the section is hidden.", - ), -``` - -- [ ] **Step 2: Add the Unleash key mapping** - -In `FeatureUnleashKey.kt`, add a branch to the `when` (keep alphabetical/grouped with the other `DISABLE_*` entries): - -```kotlin - Feature.DISABLE_RESUMING_ONGOING_SHOP_SESSIONS -> "disable_resuming_ongoing_shop_sessions" -``` - -- [ ] **Step 3: Verify it compiles** - -Run: `./gradlew :feature-flags:compileDebugKotlinAndroid` -Expected: BUILD SUCCESSFUL (an exhaustive-`when` error here would mean the mapping is missing). - -- [ ] **Step 4: Format and commit** - -```bash -./gradlew ktlintFormat -git add app/featureflags/feature-flags/src -git commit -m "featureflags: add disable_resuming_ongoing_shop_sessions kill switch" -``` - ---- - -## Task 2: Query `ongoingShopSessions` in the Home query - -**Files:** -- Modify: `app/feature/feature-home/src/main/graphql/QueryHome.graphql` - -**Interfaces:** -- Produces: `HomeQuery.Data.CurrentMember.ongoingShopSessions`, each with `.id` and `.display` (`.display.title`, `.display.subtitle`, `.display.monthlyNet?.{amount,currencyCode}`, `.display.resumeUrl`, `.display.pillowImage?.src`). Consumed by Task 3. - -**Notes:** No new query variable is added, so the generated `HomeQuery(...)` constructor arity is unchanged and existing tests that call `HomeQuery(true, false)` / `HomeQuery(true, true)` keep compiling. Gating is done in the use case (Task 3), not via an `@include` variable, to keep the blast radius small. - -- [ ] **Step 1: Add the selection** - -In `QueryHome.graphql`, inside the `currentMember { ... }` block (e.g. right after the `crossSellV2(...) { ... }` block), add: - -```graphql - ongoingShopSessions { - id - display { - title - subtitle - monthlyNet { - amount - currencyCode - } - resumeUrl - pillowImage { - src - } - } - } -``` - -- [ ] **Step 2: Regenerate Apollo sources and verify** - -Run: `./gradlew :feature-home:generateApolloSources` -Expected: BUILD SUCCESSFUL, and `HomeQuery.Data.CurrentMember.OngoingShopSession` (with a nested `Display`) is generated. If the build complains the field is unknown, the local Octopus schema is stale — run `./gradlew downloadOctopusApolloSchemaFromIntrospection` and retry. - -- [ ] **Step 3: Commit** - -```bash -git add app/feature/feature-home/src/main/graphql/QueryHome.graphql -git commit -m "feature-home: query currentMember.ongoingShopSessions" -``` - ---- - -## Task 3: Map sessions into `HomeData`, gated by the kill switch - -**Files:** -- Modify: `app/feature/feature-home/build.gradle.kts` -- Modify: `app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/data/GetHomeDataUseCase.kt` -- Test: `app/feature/feature-home/src/test/kotlin/com/hedvig/android/feature/home/home/data/GetHomeUseCaseTest.kt` - -**Interfaces:** -- Consumes: `HomeQuery.Data.CurrentMember.ongoingShopSessions` (Task 2); `Feature.DISABLE_RESUMING_ONGOING_SHOP_SESSIONS` (Task 1); `com.hedvig.android.core.uidata.UiMoney` + `com.hedvig.android.core.uidata.UiCurrencyCode`. -- Produces: - ```kotlin - data class OngoingShopSession( - val id: String, - val title: String, - val subtitle: String?, - val monthlyNet: UiMoney?, - val resumeUrl: String, - val pillowImageUrl: String?, - ) - ``` - and `HomeData.ongoingShopSessions: List` (default `emptyList()`), consumed by Tasks 4/5/7. - -- [ ] **Step 1: Add the `core-ui-data` dependency** - -In `app/feature/feature-home/build.gradle.kts`, add to the `dependencies { }` block (alphabetically near other `projects.core*` entries): - -```kotlin - implementation(projects.coreUiData) -``` - -- [ ] **Step 2: Write the failing mapping test** - -In `GetHomeUseCaseTest.kt`, add the imports at the top: - -```kotlin -import com.hedvig.android.core.uidata.UiCurrencyCode -import com.hedvig.android.feature.home.home.data.HomeData -import octopus.type.CurrencyCode -import octopus.type.buildMoney -import octopus.type.buildShopSession -import octopus.type.buildShopSessionDisplay -import assertk.assertions.containsExactly -``` -(Skip any import already present — `HomeData` and `containsExactly` are likely already imported.) - -Then add this test (uses `FakeFeatureManager(mapOf(...))`, so it must list every flag the use case reads — including the new one, set to `false` = not disabled): - -```kotlin - @Test - fun `ongoing shop sessions are mapped into HomeData when the kill switch is off`() = runTest { - val featureManager = FakeFeatureManager( - mapOf( - Feature.ENABLE_NEW_CONVERSATION_FROM_INBOX to false, - Feature.ENABLE_CLAIM_INTENT_RESUME to false, - Feature.DISABLE_RESUMING_ONGOING_SHOP_SESSIONS to false, - ), - ) - val getHomeDataUseCase = testUseCaseWithoutReminders(featureManager) - - apolloClient.registerTestResponse( - HomeQuery(true, false), - HomeQuery.Data(OctopusFakeResolver) { - currentMember = buildMember { - ongoingShopSessions = listOf( - buildShopSession { - id = "session-1" - display = buildShopSessionDisplay { - title = "Home + Accident" - subtitle = "Studio apartment, Stockholm" - monthlyNet = buildMoney { - amount = 199.0 - currencyCode = CurrencyCode.SEK - } - resumeUrl = "https://hedvig.com/resume/session-1" - pillowImage = null - } - }, - ) - } - }, - ) - apolloClient.registerTestResponse(UnreadMessageCountQuery(), UnreadMessageCountQuery.Data(OctopusFakeResolver)) - - val result = getHomeDataUseCase.invoke(true).first() - - assertThat(result) - .isNotNull() - .isRight() - .prop(HomeData::ongoingShopSessions) - .containsExactly( - OngoingShopSession( - id = "session-1", - title = "Home + Accident", - subtitle = "Studio apartment, Stockholm", - monthlyNet = UiMoney(199.0, UiCurrencyCode.SEK), - resumeUrl = "https://hedvig.com/resume/session-1", - pillowImageUrl = null, - ), - ) - } -``` - -Also add the import for the model: -```kotlin -import com.hedvig.android.feature.home.home.data.OngoingShopSession -``` - -- [ ] **Step 3: Run it and confirm it fails to compile / fails** - -Run: `./gradlew :feature-home:testDebugUnitTest --tests "com.hedvig.android.feature.home.home.data.GetHomeUseCaseTest"` -Expected: FAIL — `OngoingShopSession` and `HomeData.ongoingShopSessions` don't exist yet (compile error), or the assertion fails. - -- [ ] **Step 4: Add the `OngoingShopSession` model and the `HomeData` field** - -In `GetHomeDataUseCase.kt`, add the model near the other data-layer models (e.g. just below the `HomeData` data class, or beside `RecommendedCrossSell` usages): - -```kotlin -data class OngoingShopSession( - val id: String, - val title: String, - val subtitle: String?, - val monthlyNet: UiMoney?, - val resumeUrl: String, - val pillowImageUrl: String?, -) -``` - -Add the field to `data class HomeData(...)` with a default so existing construction sites (tests, demo) stay terse: - -```kotlin - val ongoingShopSessions: List = emptyList(), -``` - -Add imports to the file: -```kotlin -import com.hedvig.android.core.uidata.UiCurrencyCode -import com.hedvig.android.core.uidata.UiMoney -``` - -- [ ] **Step 5: Read the kill switch and map the sessions** - -In `GetHomeDataUseCaseImpl.invoke(...)`, change the outer flag read so both flags are read, then gate the mapping. - -Replace the current outer: -```kotlin - return featureManager.isFeatureEnabled(Feature.ENABLE_CLAIM_INTENT_RESUME) - .flatMapLatest { resumeClaimEnabled -> - combine( -``` -with: -```kotlin - return combine( - featureManager.isFeatureEnabled(Feature.ENABLE_CLAIM_INTENT_RESUME), - featureManager.isFeatureEnabled(Feature.DISABLE_RESUMING_ONGOING_SHOP_SESSIONS), - ::Pair, - ).flatMapLatest { (resumeClaimEnabled, disableShopSessions) -> - combine( -``` -(The inner `combine(...)` over the 6 data flows is unchanged — do not add a 7th flow to it.) - -Inside the `either { }` block (next to where `crossSellsData` / `recommendedCrossSell` are built), add: -```kotlin - val ongoingShopSessions = if (disableShopSessions) { - emptyList() - } else { - homeQueryData.currentMember.ongoingShopSessions.map { session -> - OngoingShopSession( - id = session.id, - title = session.display.title, - subtitle = session.display.subtitle, - monthlyNet = session.display.monthlyNet?.let { - UiMoney(it.amount, UiCurrencyCode.fromCurrencyCode(it.currencyCode)) - }, - resumeUrl = session.display.resumeUrl, - pillowImageUrl = session.display.pillowImage?.src, - ) - } - } -``` - -Pass it into the `HomeData(...)` constructor at the end of the `either { }` block: -```kotlin - ongoingShopSessions = ongoingShopSessions, -``` - -- [ ] **Step 6: Add the gating (kill-switch-on) test** - -In `GetHomeUseCaseTest.kt`, add: - -```kotlin - @Test - fun `ongoing shop sessions are dropped when the kill switch is on`() = runTest { - val featureManager = FakeFeatureManager( - mapOf( - Feature.ENABLE_NEW_CONVERSATION_FROM_INBOX to false, - Feature.ENABLE_CLAIM_INTENT_RESUME to false, - Feature.DISABLE_RESUMING_ONGOING_SHOP_SESSIONS to true, - ), - ) - val getHomeDataUseCase = testUseCaseWithoutReminders(featureManager) - - apolloClient.registerTestResponse( - HomeQuery(true, false), - HomeQuery.Data(OctopusFakeResolver) { - currentMember = buildMember { - ongoingShopSessions = listOf( - buildShopSession { - id = "session-1" - display = buildShopSessionDisplay { - title = "Home + Accident" - resumeUrl = "https://hedvig.com/resume/session-1" - } - }, - ) - } - }, - ) - apolloClient.registerTestResponse(UnreadMessageCountQuery(), UnreadMessageCountQuery.Data(OctopusFakeResolver)) - - val result = getHomeDataUseCase.invoke(true).first() - - assertThat(result) - .isNotNull() - .isRight() - .prop(HomeData::ongoingShopSessions) - .isEmpty() - } -``` - -- [ ] **Step 7: Patch existing map-based `FakeFeatureManager` sites** - -Because the use case now reads a second flag, any test that builds `FakeFeatureManager(mapOf(...))` must include the new flag, or `isFeatureEnabled` will block on the turbine and the test will hang. Add the entry `Feature.DISABLE_RESUMING_ONGOING_SHOP_SESSIONS to false,` to **each** of the 4 map literals in `GetHomeUseCaseTest.kt`. They are the blocks starting near lines **457, 530, 590, 749**, each currently containing: -```kotlin - Feature.ENABLE_NEW_CONVERSATION_FROM_INBOX to ..., - Feature.ENABLE_CLAIM_INTENT_RESUME to false, -``` -Add the new line right after the `ENABLE_CLAIM_INTENT_RESUME` entry in all four: -```kotlin - Feature.DISABLE_RESUMING_ONGOING_SHOP_SESSIONS to false, -``` -The `FakeFeatureManager(true)` sites (all-true) need no change — they already resolve every flag. - -- [ ] **Step 8: Run the module's use-case tests and confirm green** - -Run: `./gradlew :feature-home:testDebugUnitTest --tests "com.hedvig.android.feature.home.home.data.GetHomeUseCaseTest"` -Expected: PASS (both new tests pass; no existing test hangs or fails). - -- [ ] **Step 9: Format and commit** - -```bash -./gradlew ktlintFormat -git add app/feature/feature-home/build.gradle.kts app/feature/feature-home/src/main app/feature/feature-home/src/test -git commit -m "feature-home: map ongoing shop sessions into HomeData behind the kill switch" -``` - ---- - -## Task 4: Expose `ongoingShopSessions` on the home UI state - -**Files:** -- Modify: `app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomePresenter.kt` -- Test: `app/feature/feature-home/src/test/kotlin/com/hedvig/android/feature/home/home/ui/HomePresenterTest.kt` - -**Interfaces:** -- Consumes: `HomeData.ongoingShopSessions` (Task 3). -- Produces: `HomeUiState.Success.ongoingShopSessions: List` (default `emptyList()`), consumed by Task 5. - -- [ ] **Step 1: Write the failing propagation test** - -In `HomePresenterTest.kt`, add a test that pushes a `HomeData` carrying one session and asserts it reaches `HomeUiState.Success`. Model the presenter construction on the existing tests (7 constructor args). Add imports: -```kotlin -import com.hedvig.android.core.uidata.UiMoney -import com.hedvig.android.core.uidata.UiCurrencyCode -import com.hedvig.android.feature.home.home.data.OngoingShopSession -import assertk.assertions.containsExactly -``` -Test: -```kotlin - @Test - fun `ongoing shop sessions propagate to the success ui state`() = runTest { - val getHomeDataUseCase = TestGetHomeDataUseCase() - val homePresenter = HomePresenter( - getHomeDataUseCase, - SeenImportantMessagesStorageImpl(), - FakeCrossSellHomeNotificationService(), - ApplicationScope(backgroundScope), - false, - TestDeleteClaimIntentDraftUseCase(), - FakeGetMemberQuickActionsUseCase(emptyList().right()), - ) - - val session = OngoingShopSession( - id = "session-1", - title = "Home + Accident", - subtitle = null, - monthlyNet = UiMoney(199.0, UiCurrencyCode.SEK), - resumeUrl = "https://hedvig.com/resume/session-1", - pillowImageUrl = null, - ) - - homePresenter.test(HomeUiState.Loading) { - assertThat(awaitItem()).isEqualTo(HomeUiState.Loading) - getHomeDataUseCase.responseTurbine.add( - someIrrelevantHomeDataInstance.copy(ongoingShopSessions = listOf(session)).right(), - ) - val success = awaitItem() - assertThat(success) - .isInstanceOf() - .prop(HomeUiState.Success::ongoingShopSessions) - .containsExactly(session) - } - } -``` -If `someIrrelevantHomeDataInstance` is not defined in this file, build a minimal `HomeData(...)` inline instead (see the existing `a successful response, properly propagates the info to the UI State` test for the field list) and set `ongoingShopSessions = listOf(session)`. - -- [ ] **Step 2: Run it and confirm it fails** - -Run: `./gradlew :feature-home:testDebugUnitTest --tests "com.hedvig.android.feature.home.home.ui.HomePresenterTest"` -Expected: FAIL — `HomeUiState.Success.ongoingShopSessions` does not exist yet. - -- [ ] **Step 3: Add the field to `HomeUiState.Success`** - -In `HomePresenter.kt`, in `data class Success(...)`, add (near `crossSellsPartition`): -```kotlin - val ongoingShopSessions: List = emptyList(), -``` -Add the import: -```kotlin -import com.hedvig.android.feature.home.home.data.OngoingShopSession -``` - -- [ ] **Step 4: Thread it through `SuccessData`** - -In `private data class SuccessData(...)`, add: -```kotlin - val ongoingShopSessions: List, -``` -In `SuccessData.fromLastState(...)`, add: -```kotlin - ongoingShopSessions = lastState.ongoingShopSessions, -``` -In `SuccessData.fromHomeData(...)` return, add: -```kotlin - ongoingShopSessions = homeData.ongoingShopSessions, -``` -In the `HomeUiState.Success(...)` construction (where `crossSellsPartition = successData.crossSellsPartition` is set), add: -```kotlin - ongoingShopSessions = successData.ongoingShopSessions, -``` - -- [ ] **Step 5: Run the presenter tests and confirm green** - -Run: `./gradlew :feature-home:testDebugUnitTest --tests "com.hedvig.android.feature.home.home.ui.HomePresenterTest"` -Expected: PASS. - -- [ ] **Step 6: Format and commit** - -```bash -./gradlew ktlintFormat -git add app/feature/feature-home/src -git commit -m "feature-home: expose ongoingShopSessions on the home ui state" -``` - ---- - -## Task 5: Render the "Your quotes" section from sessions - -**Files:** -- Modify: `app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomeDestination.kt` - -**Interfaces:** -- Consumes: `HomeUiState.Success.ongoingShopSessions` (Task 4); the existing `openUrl: (String) -> Unit` param already threaded into `HomeDestination` (opens a web URL — the correct handler for `resumeUrl`, which needs no auth). -- Produces: a `QuotesSection` composable; the `HomeSection.Quotes` entry. - -**Notes:** This task switches what the section *renders* but does not yet remove the now-unused `crossSellsPartition.offersCrossSell` field — that cleanup is Task 6, kept separate so it can be reviewed independently. The section is a pure UI render of state; there is no presenter/unit test for Compose here (consistent with the module — the section's data is covered by Tasks 3–4). Verification is compilation + the `@Preview`. - -- [ ] **Step 1: Rename the section enum `Offers` → `Quotes`** - -In `HomeDestination.kt`, in `private enum class HomeSection`, rename `Offers` to `Quotes`. Update the reference in `homeSectionOrder` (`HomeSection.Offers` → `HomeSection.Quotes`). - -- [ ] **Step 2: Point the visibility guard at the sessions** - -Find the `HomeSection.Offers -> { uiState.crossSellsPartition.offersCrossSell != null }` branch (currently ~line 664) and change it to: -```kotlin - HomeSection.Quotes -> { - uiState.ongoingShopSessions.isNotEmpty() - } -``` - -- [ ] **Step 3: Point the render branch at the sessions** - -Find the `HomeSection.Offers -> uiState.crossSellsPartition.offersCrossSell?.let { ... OffersSection(...) }` branch (currently ~line 891) and replace it with: -```kotlin - HomeSection.Quotes -> uiState.ongoingShopSessions.takeIf { it.isNotEmpty() }?.let { sessions -> - QuotesSection( - sessions = sessions, - onResumeClick = openUrl, - imageLoader = imageLoader, - horizontalInsets = horizontalInsets, - ) - } -``` - -- [ ] **Step 4: Replace `OffersSection` with `QuotesSection`** - -Replace the entire `private fun OffersSection(...)` composable (currently ~lines 1101–1171) with: -```kotlin -@Composable -private fun QuotesSection( - sessions: List, - onResumeClick: (String) -> Unit, - imageLoader: ImageLoader, - horizontalInsets: PaddingValues, -) { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(horizontalInsets), - ) { - HedvigText( - text = stringResource(Res.string.HOME_QUOTES_SECTION_TITLE), - style = HedvigTheme.typography.headlineSmall, - modifier = Modifier.semantics { heading() }, - ) - for (session in sessions) { - HedvigCard( - onClick = { onResumeClick(session.resumeUrl) }, - color = HedvigTheme.colorScheme.fillNegative, - borderColor = HedvigTheme.colorScheme.borderPrimary, - modifier = Modifier - .fillMaxWidth() - .hedvigDropShadow(HedvigTheme.shapes.cornerXLarge), - ) { - Column(Modifier.padding(16.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - if (session.pillowImageUrl != null) { - AsyncImage( - model = session.pillowImageUrl, - contentDescription = null, - imageLoader = imageLoader, - contentScale = ContentScale.Fit, - modifier = Modifier.size(48.dp), - ) - Spacer(Modifier.width(12.dp)) - } - Column(Modifier.weight(1f)) { - HedvigText(text = session.title, style = HedvigTheme.typography.bodySmall) - val secondary = session.subtitle ?: session.monthlyNet?.toString() - if (secondary != null) { - HedvigText( - text = secondary, - style = HedvigTheme.typography.label, - color = HedvigTheme.colorScheme.textSecondary, - ) - } - } - } - Spacer(Modifier.height(12.dp)) - // TODO: Add "Resume" / "Fortsätt" to Lokalise - HedvigButton( - text = "Resume", - onClick = { onResumeClick(session.resumeUrl) }, - buttonStyle = Secondary, - buttonSize = ButtonSize.Medium, - enabled = true, - shape = HedvigTheme.shapes.cornerFull, - modifier = Modifier.fillMaxWidth(), - ) - } - } - } - } -} -``` -Add the import for the model: -```kotlin -import com.hedvig.android.feature.home.home.data.OngoingShopSession -``` -(All other symbols — `HedvigCard`, `HedvigButton`, `AsyncImage`, `Secondary`, `ButtonSize`, `hedvigDropShadow`, etc. — are already imported for the old `OffersSection`.) - -- [ ] **Step 5: Update the `@Preview` to exercise the new section** - -In the preview's `HomeUiState.Success(...)` (the block currently containing `crossSellsPartition = CrossSellsPartition(offersCrossSell = RecommendedCrossSell(...), discoverCrossSells = emptyList())`, ~line 1479), add a sessions argument so the section renders in the preview: -```kotlin - ongoingShopSessions = listOf( - OngoingShopSession( - id = "preview-1", - title = "Home + Accident", - subtitle = "Studio apartment, Stockholm", - monthlyNet = null, - resumeUrl = "", - pillowImageUrl = null, - ), - ), -``` -Leave the `crossSellsPartition = CrossSellsPartition(offersCrossSell = ...)` argument as-is for now (it is removed in Task 6). - -- [ ] **Step 6: Verify it compiles** - -Run: `./gradlew :feature-home:compileDebugKotlin` -Expected: BUILD SUCCESSFUL. - -- [ ] **Step 7: Format and commit** - -```bash -./gradlew ktlintFormat -git add app/feature/feature-home/src/main -git commit -m "feature-home: render the Your quotes section from ongoing shop sessions" -``` - ---- - -## Task 6: Remove the placeholder cross-sell from the quotes section - -**Files:** -- Modify: `app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomePresenter.kt` -- Modify: `app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomeDestination.kt` -- Test: `app/feature/feature-home/src/test/kotlin/com/hedvig/android/feature/home/home/ui/HomePresenterTest.kt` - -**Interfaces:** -- Removes `CrossSellsPartition.offersCrossSell`. `CrossSellsPartition` keeps only `discoverCrossSells: List`, still consumed by the `DiscoverInsurances` section. The recommended cross-sell remains in `CrossSellSheetData` and still feeds the cross-sell bottom sheet (`crossSellsAction`) — do not touch that. - -**Notes:** This is the "commit to approach #1" cleanup. It is separate from Task 5 so a reviewer can accept the new UI first. - -- [ ] **Step 1: Drop the field from `CrossSellsPartition` and update its doc** - -In `HomePresenter.kt`, remove `val offersCrossSell: RecommendedCrossSell? = null,` from `data class CrossSellsPartition(...)`, leaving: -```kotlin -internal data class CrossSellsPartition( - val discoverCrossSells: List = emptyList(), -) -``` -Update the KDoc above it so it describes the current reality (it currently mentions "Offers"): -```kotlin -/** - * The home screen surfaces cross-sells in the discover carousel and the "Discover our insurances" - * list. This is the single place that decides which cross-sells go where. - */ -``` -In `partitionCrossSells(...)`, remove the `offersCrossSell = crossSells.recommendedCrossSell,` line and the `// WS0 placeholder ...` comment, leaving: -```kotlin -internal fun partitionCrossSells(crossSells: CrossSellSheetData): CrossSellsPartition { - return CrossSellsPartition( - discoverCrossSells = crossSells.otherCrossSells, - ) -} -``` -If the `RecommendedCrossSell` import becomes unused in this file after this, remove it. (It is still used via `homeData.crossSells.recommendedCrossSell` for `crossSellsAction`, so it likely stays — let the compiler/ktlint tell you.) - -- [ ] **Step 2: Remove the dead preview argument** - -In `HomeDestination.kt`, in the preview's `CrossSellsPartition(...)`, remove the `offersCrossSell = RecommendedCrossSell(...)` argument (the whole `RecommendedCrossSell(...)` block), leaving: -```kotlin - crossSellsPartition = CrossSellsPartition( - discoverCrossSells = emptyList(), - ), -``` - -- [ ] **Step 3: Update the 3 presenter-test references** - -In `HomePresenterTest.kt`, remove the `offersCrossSell = testCrossSell` usages: -- Line ~220: `crossSellsPartition = CrossSellsPartition(offersCrossSell = testCrossSell),` → `crossSellsPartition = CrossSellsPartition(),` -- Lines ~561 and ~704: remove the `offersCrossSell = testCrossSell,` line from each `CrossSellsPartition(...)` literal (keep the `discoverCrossSells = ...` argument if present; if `offersCrossSell` was the only argument, leave `CrossSellsPartition()`). - -Read each site before editing to preserve the surrounding `discoverCrossSells` argument. - -- [ ] **Step 4: Compile and run the full module test suite** - -Run: `./gradlew :feature-home:testDebugUnitTest` -Expected: BUILD SUCCESSFUL, all tests PASS. (Confirms nothing else read `offersCrossSell`.) - -- [ ] **Step 5: Format and commit** - -```bash -./gradlew ktlintFormat -git add app/feature/feature-home/src -git commit -m "feature-home: drop the placeholder cross-sell from the quotes section" -``` - ---- - -## Task 7: Show sessions in demo mode - -**Files:** -- Modify: `app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/data/GetHomeDataUseCaseDemo.kt` - -**Interfaces:** -- Consumes: `HomeData.ongoingShopSessions` + `OngoingShopSession` (Task 3). - -**Notes:** Demo mode never reads feature flags, so the section shows whenever `ongoingShopSessions` is non-empty. Keep the existing `crossSells` block as-is (it still drives the cross-sell sheet in demo). - -- [ ] **Step 1: Add sample session data** - -In `GetHomeDataUseCaseDemo.kt`, add to the `HomeData(...)` constructor: -```kotlin - ongoingShopSessions = listOf( - OngoingShopSession( - id = "demo-session-1", - title = "Home + Accident", - subtitle = "Studio apartment, Stockholm", - monthlyNet = UiMoney(199.0, UiCurrencyCode.SEK), - resumeUrl = "https://www.hedvig.com", - pillowImageUrl = null, - ), - ), -``` -Add imports: -```kotlin -import com.hedvig.android.core.uidata.UiCurrencyCode -import com.hedvig.android.core.uidata.UiMoney -``` - -- [ ] **Step 2: Compile the module** - -Run: `./gradlew :feature-home:compileDebugKotlin` -Expected: BUILD SUCCESSFUL. - -- [ ] **Step 3: Format and commit** - -```bash -./gradlew ktlintFormat -git add app/feature/feature-home/src/main -git commit -m "feature-home: show a sample ongoing shop session in demo mode" -``` - ---- - -## Final verification - -- [ ] Run the full module test suite: `./gradlew :feature-home:testDebugUnitTest` → all PASS. -- [ ] Confirm formatting: `./gradlew ktlintCheck` → no violations. -- [ ] (Optional, manual) Run the app in demo mode and confirm the "Your quotes" section shows the sample session with a working "Resume" button; toggle `disable_resuming_ongoing_shop_sessions` on in a real build and confirm the section disappears. - -## Notes / decisions baked into this plan - -- **Approach #1 (chosen):** the recommended cross-sell no longer appears in the home "Your quotes" section. It is *not* deleted from the data layer — it still feeds the cross-sell bottom sheet via `CrossSellSheetData.recommendedCrossSell`, untouched. -- **Kill-switch gating is in the use case** (drop to empty list when on), not via a GraphQL `@include` variable, to avoid changing the generated `HomeQuery` constructor arity and breaking every existing test call site. Cost: the field is fetched even when the switch is on (the rare/rollback case); this is negligible. -- **Resume URL** is opened with the existing `openUrl` lambda (already threaded into `HomeDestination`), which matches the backend contract ("open directly in the device browser, no auth required"). No new `:app` plumbing. -- **No bootstrap** for the flag: natural never-fetched default (section available, but empty until data loads) is safe and correct. -- **`monthlyGross`, `lastActivityAt`, `validTo`** are available on the backend `display` type but are not queried in v1 (YAGNI). Add them if design wants a struck-through price, a relative "2 days ago" label, or client-side expiry hiding. -- **"Resume" button copy** is hardcoded with a Lokalise TODO. If a suitable generic key already exists in `strings.xml` (e.g. a "Continue" string), prefer reusing it over the hardcode. From 4c0fd7f2be075da32bf013b5098d0245234a1722 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Thu, 6 Aug 2026 17:28:47 +0200 Subject: [PATCH 9/9] feature-home: use general_continue_button for the resume-session button --- .../hedvig/android/feature/home/home/ui/HomeDestination.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomeDestination.kt b/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomeDestination.kt index 99bd6a79c8..3d23045144 100644 --- a/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomeDestination.kt +++ b/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomeDestination.kt @@ -206,6 +206,7 @@ import hedvig.resources.Res import hedvig.resources.TOAST_NEW_OFFER import hedvig.resources.blur_background import hedvig.resources.general_cancel_button +import hedvig.resources.general_continue_button import hedvig.resources.home_tab_claim_button_text import hedvig.resources.home_tab_get_help import hedvig.resources.home_tab_welcome_title_without_name @@ -1152,9 +1153,8 @@ private fun QuotesSection( } } Spacer(Modifier.height(12.dp)) - // TODO: Add "Resume" / "Fortsätt" to Lokalise HedvigButton( - text = "Resume", + text = stringResource(Res.string.general_continue_button), onClick = { onResumeClick(session.resumeUrl) }, buttonStyle = Secondary, buttonSize = ButtonSize.Medium,