diff --git a/app/app/build.gradle.kts b/app/app/build.gradle.kts index 6ef0c2a186..5c11745bc3 100644 --- a/app/app/build.gradle.kts +++ b/app/app/build.gradle.kts @@ -167,7 +167,7 @@ dependencies { implementation(projects.dataAddons) implementation(projects.dataChangetier) implementation(projects.dataChat) - + implementation(projects.dataClaimIntent) implementation(projects.dataContract) implementation(projects.dataConversations) implementation(projects.dataCrossSellAfterClaimClosed) diff --git a/app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigEntryProvider.kt b/app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigEntryProvider.kt index 36905da0e5..77d48d8a39 100644 --- a/app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigEntryProvider.kt +++ b/app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigEntryProvider.kt @@ -230,8 +230,14 @@ private fun EntryProviderScope.addHomeEntries( backstack.add(CoInsuredAddInfoKey(contractId, type)) }, navigateToHelpCenter = { backstack.add(HelpCenterKey) }, - navigateToClaimChat = { - backstack.add(ClaimChatKey(messageId = null, isDevelopmentFlow = false)) + navigateToClaimChat = { resumeClaim -> + backstack.add( + ClaimChatKey( + messageId = null, + isDevelopmentFlow = false, + resumeClaim = resumeClaim, + ), + ) }, navigateToChipIdScreen = { backstack.add(ChipIdKey()) }, openAppSettings = externalNavigator::openAppSettings, @@ -471,8 +477,8 @@ private fun EntryProviderScope.addChatEntries( }, onNavigateToImageViewer = onNavigateToImageViewer, onNavigateToNewConversation = navigateToNewConversation, - navigateToClaimChat = { - backstack.add(ClaimChatKey(messageId = null, isDevelopmentFlow = false)) + navigateToClaimChat = { resumeClaim -> + backstack.add(ClaimChatKey(messageId = null, isDevelopmentFlow = false, resumeClaim = resumeClaim)) }, backstack = backstack, ) diff --git a/app/core/core-resources/src/androidMain/res/values-sv-rSE/strings.xml b/app/core/core-resources/src/androidMain/res/values-sv-rSE/strings.xml index 671a6ac4bf..6bda39074f 100644 --- a/app/core/core-resources/src/androidMain/res/values-sv-rSE/strings.xml +++ b/app/core/core-resources/src/androidMain/res/values-sv-rSE/strings.xml @@ -26,6 +26,10 @@ Lägg till skydd Populär Aktiv + Mer skydd för bilen + Drulle: kupé, nycklar, feltankning + Hyrbil när din egen bil repareras + Självriskreducering vid kollision Bil Plus Fortsätt Den här tilläggsförsäkringen är aktiv och gäller ihop med din försäkring.\n\nVill du ta bort den behöver du göra det från försäkringen den är kopplad till. @@ -63,6 +67,10 @@ Avsluta %1$s Om du avslutar detta tillägg ingår inte det extra skyddet längre i din hemförsäkring. Avsluta %1$s + Extra trygghet när du reser + Res upp till 60 dagar i sträck + Försenat flyg och bagage + Gäller för alla medförsäkrade OK Lyssna Skicka @@ -714,6 +722,23 @@ Den här ändringar börjar gälla %s. Tillägg avslutade Ja, radera + Fortsätt + Försäkringsärende + Ditt sparade utkast raderas permanent. + Radera + Radera utkast? + Draft + Om du startar en ny anmälan raderas ditt sparade utkast + Fortsätt anmälan + Starta ny anmälan + Du har en påbörjad anmälan + Vänligen starta en ny anmälan + Ditt utkast har gått ut + Fortsätt där du slutade + Din anmälan sparas automatiskt i 7 dagar. + Ja, lämna + Lämna anmälan? + Började %1$s Spara och fortsätt Inga träffar på din sökning Sök diff --git a/app/core/core-resources/src/androidMain/res/values/strings.xml b/app/core/core-resources/src/androidMain/res/values/strings.xml index 6657ce11d1..0e0b87908c 100644 --- a/app/core/core-resources/src/androidMain/res/values/strings.xml +++ b/app/core/core-resources/src/androidMain/res/values/strings.xml @@ -26,6 +26,10 @@ Add coverage Popular Active + More for your car insurance + All-risk: interior, keys, misfuelling + Compensation for rental car + Lower deductible for collision Car Plus Continue This addon is active and linked to your insurance.\n\nTo remove it, go to the insurance it’s linked to. @@ -63,6 +67,10 @@ Cancel %1$s By canceling this extended coverage, your home insurance will no longer include this extra protection. Cancel %1$s + Add extra safety when traveling + Travel up to 60 days in a row + Delayed bags and flights + Applies to all co-insured OK Listen Send @@ -714,6 +722,23 @@ This change will take effect from %s. Removed successfully Yes, remove + Continue + Insurance case + Your saved draft will be permanently deleted. + Delete + Delete draft? + Draft + Starting a new claim will delete your saved draft + Continue draft + Start new claim + You have a draft claim + Please make a new claim + Your draft has expired + Continue where you stopped + Your claim is automatically saved for 7 days. + Yes, leave + Leave claim? + Started %1$s Save and continue No results for your search Search diff --git a/app/core/core-resources/src/commonMain/composeResources/values-sv-rSE/strings.xml b/app/core/core-resources/src/commonMain/composeResources/values-sv-rSE/strings.xml index 42758c2870..9b00cccdb5 100644 --- a/app/core/core-resources/src/commonMain/composeResources/values-sv-rSE/strings.xml +++ b/app/core/core-resources/src/commonMain/composeResources/values-sv-rSE/strings.xml @@ -25,6 +25,10 @@ Lägg till skydd Populär Aktiv + Mer skydd för bilen + Drulle: kupé, nycklar, feltankning + Hyrbil när din egen bil repareras + Självriskreducering vid kollision Bil Plus Fortsätt Den här tilläggsförsäkringen är aktiv och gäller ihop med din försäkring.\n\nVill du ta bort den behöver du göra det från försäkringen den är kopplad till. @@ -62,6 +66,10 @@ Avsluta %1$s Om du avslutar detta tillägg ingår inte det extra skyddet längre i din hemförsäkring. Avsluta %1$s + Extra trygghet när du reser + Res upp till 60 dagar i sträck + Försenat flyg och bagage + Gäller för alla medförsäkrade OK Lyssna Skicka @@ -713,6 +721,23 @@ Den här ändringar börjar gälla %1$s. Tillägg avslutade Ja, radera + Fortsätt + Försäkringsärende + Ditt sparade utkast raderas permanent. + Radera + Radera utkast? + Draft + Om du startar en ny anmälan raderas ditt sparade utkast + Fortsätt anmälan + Starta ny anmälan + Du har en påbörjad anmälan + Vänligen starta en ny anmälan + Ditt utkast har gått ut + Fortsätt där du slutade + Din anmälan sparas automatiskt i 7 dagar. + Ja, lämna + Lämna anmälan? + Började %1$s Spara och fortsätt Inga träffar på din sökning Sök diff --git a/app/core/core-resources/src/commonMain/composeResources/values/strings.xml b/app/core/core-resources/src/commonMain/composeResources/values/strings.xml index 6428d4a37d..eabd4337b2 100644 --- a/app/core/core-resources/src/commonMain/composeResources/values/strings.xml +++ b/app/core/core-resources/src/commonMain/composeResources/values/strings.xml @@ -25,6 +25,10 @@ Add coverage Popular Active + More for your car insurance + All-risk: interior, keys, misfuelling + Compensation for rental car + Lower deductible for collision Car Plus Continue This addon is active and linked to your insurance.\n\nTo remove it, go to the insurance it’s linked to. @@ -62,6 +66,10 @@ Cancel %1$s By canceling this extended coverage, your home insurance will no longer include this extra protection. Cancel %1$s + Add extra safety when traveling + Travel up to 60 days in a row + Delayed bags and flights + Applies to all co-insured OK Listen Send @@ -713,6 +721,23 @@ This change will take effect from %1$s. Removed successfully Yes, remove + Continue + Insurance case + Your saved draft will be permanently deleted. + Delete + Delete draft? + Draft + Starting a new claim will delete your saved draft + Continue draft + Start new claim + You have a draft claim + Please make a new claim + Your draft has expired + Continue where you stopped + Your claim is automatically saved for 7 days. + Yes, leave + Leave claim? + Started %1$s Save and continue No results for your search Search diff --git a/app/data/data-claim-intent/build.gradle.kts b/app/data/data-claim-intent/build.gradle.kts new file mode 100644 index 0000000000..fba12973f6 --- /dev/null +++ b/app/data/data-claim-intent/build.gradle.kts @@ -0,0 +1,21 @@ +plugins { + id("hedvig.multiplatform.library") + id("hedvig.gradle.plugin") +} + +hedvig { + apollo("octopus") +} + +kotlin { + sourceSets { + commonMain.dependencies { + implementation(libs.apollo.normalizedCache) + implementation(libs.arrow.core) + implementation(projects.apolloCore) + implementation(projects.apolloOctopusPublic) + implementation(projects.coreCommonPublic) + implementation(projects.loggingPublic) + } + } +} diff --git a/app/data/data-claim-intent/src/commonMain/graphql/MutationClaimIntentDeleteDraft.graphql b/app/data/data-claim-intent/src/commonMain/graphql/MutationClaimIntentDeleteDraft.graphql new file mode 100644 index 0000000000..e0e7c78cff --- /dev/null +++ b/app/data/data-claim-intent/src/commonMain/graphql/MutationClaimIntentDeleteDraft.graphql @@ -0,0 +1,3 @@ +mutation ClaimIntentDeleteDraft($id: ID!) { + claimIntentDeleteDraft(id: $id) +} diff --git a/app/data/data-claim-intent/src/commonMain/graphql/QueryResumableClaimIntent.graphql b/app/data/data-claim-intent/src/commonMain/graphql/QueryResumableClaimIntent.graphql new file mode 100644 index 0000000000..fea47c6e9a --- /dev/null +++ b/app/data/data-claim-intent/src/commonMain/graphql/QueryResumableClaimIntent.graphql @@ -0,0 +1,9 @@ +query ResumableClaimIntent { + currentMember { + resumableClaimIntent { + id + displayName + createdAt + } + } +} diff --git a/app/data/data-claim-intent/src/commonMain/kotlin/com/hedvig/android/data/claimintent/DeleteClaimIntentDraftUseCase.kt b/app/data/data-claim-intent/src/commonMain/kotlin/com/hedvig/android/data/claimintent/DeleteClaimIntentDraftUseCase.kt new file mode 100644 index 0000000000..4290082106 --- /dev/null +++ b/app/data/data-claim-intent/src/commonMain/kotlin/com/hedvig/android/data/claimintent/DeleteClaimIntentDraftUseCase.kt @@ -0,0 +1,38 @@ +package com.hedvig.android.data.claimintent + +import arrow.core.Either +import arrow.core.raise.either +import arrow.core.raise.ensure +import com.apollographql.apollo.ApolloClient +import com.hedvig.android.apollo.safeExecute +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.core.common.di.AppScope +import com.hedvig.android.logger.logcat +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import octopus.ClaimIntentDeleteDraftMutation + +interface DeleteClaimIntentDraftUseCase { + suspend fun invoke(id: String): Either +} + +@Inject +@ContributesBinding(AppScope::class) +internal class DeleteClaimIntentDraftUseCaseImpl( + private val apolloClient: ApolloClient, +) : DeleteClaimIntentDraftUseCase { + override suspend fun invoke(id: String): Either { + return either { + val deleted = apolloClient + .mutation(ClaimIntentDeleteDraftMutation(id)) + .safeExecute() + .mapLeft { error -> + logcat(operationError = error) { "DeleteClaimIntentDraftUseCase failed with $error" } + ErrorMessage() + } + .bind() + .claimIntentDeleteDraft + ensure(deleted) { ErrorMessage() } + } + } +} diff --git a/app/data/data-claim-intent/src/commonMain/kotlin/com/hedvig/android/data/claimintent/GetResumableClaimIntentUseCase.kt b/app/data/data-claim-intent/src/commonMain/kotlin/com/hedvig/android/data/claimintent/GetResumableClaimIntentUseCase.kt new file mode 100644 index 0000000000..3315bb773f --- /dev/null +++ b/app/data/data-claim-intent/src/commonMain/kotlin/com/hedvig/android/data/claimintent/GetResumableClaimIntentUseCase.kt @@ -0,0 +1,50 @@ +package com.hedvig.android.data.claimintent + +import arrow.core.Either +import arrow.core.raise.either +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.cache.normalized.FetchPolicy +import com.apollographql.apollo.cache.normalized.fetchPolicy +import com.hedvig.android.apollo.safeExecute +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.core.common.di.AppScope +import com.hedvig.android.logger.logcat +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import octopus.ResumableClaimIntentQuery + +interface GetResumableClaimIntentUseCase { + /** + * A null right side means the member has no resumable draft claim. + */ + suspend fun invoke(): Either +} + +@Inject +@ContributesBinding(AppScope::class) +internal class GetResumableClaimIntentUseCaseImpl( + private val apolloClient: ApolloClient, +) : GetResumableClaimIntentUseCase { + override suspend fun invoke(): Either { + return either { + apolloClient + .query(ResumableClaimIntentQuery()) + .fetchPolicy(FetchPolicy.NetworkOnly) + .safeExecute() + .mapLeft { error -> + logcat(operationError = error) { "GetResumableClaimIntentUseCase failed with $error" } + ErrorMessage() + } + .bind() + .currentMember + .resumableClaimIntent + ?.let { resumableClaimIntent -> + ResumableClaimIntent( + id = resumableClaimIntent.id, + displayName = resumableClaimIntent.displayName, + startedAt = resumableClaimIntent.createdAt, + ) + } + } + } +} diff --git a/app/data/data-claim-intent/src/commonMain/kotlin/com/hedvig/android/data/claimintent/ResumableClaimIntent.kt b/app/data/data-claim-intent/src/commonMain/kotlin/com/hedvig/android/data/claimintent/ResumableClaimIntent.kt new file mode 100644 index 0000000000..143b45763b --- /dev/null +++ b/app/data/data-claim-intent/src/commonMain/kotlin/com/hedvig/android/data/claimintent/ResumableClaimIntent.kt @@ -0,0 +1,9 @@ +package com.hedvig.android.data.claimintent + +import kotlin.time.Instant + +data class ResumableClaimIntent( + val id: String, + val displayName: String?, + val startedAt: Instant, +) diff --git a/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/Button.kt b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/Button.kt index d963ed99e7..a188b0b647 100644 --- a/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/Button.kt +++ b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/Button.kt @@ -195,6 +195,31 @@ fun HedvigRedTextButton( } } +/** + * A secondary-fill button with a destructive (red) label, e.g. a "Delete" action that sits next to + * a primary action. Reuses the secondary style's red content token rather than a bespoke color. + */ +@Composable +fun HedvigSecondaryRedTextButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + buttonSize: ButtonSize = ButtonSize.Medium, + interactionSource: MutableInteractionSource? = null, +) { + HedvigButton( + onClick = onClick, + enabled = enabled, + modifier = modifier, + buttonStyle = ButtonDefaults.ButtonStyle.Secondary, + buttonSize = buttonSize, + interactionSource = interactionSource, + ) { + HedvigText(text, color = ButtonDefaults.ButtonStyle.Secondary.style.buttonColors.redTextColor) + } +} + @Composable fun HedvigButtonGhostWithBorder( text: String, diff --git a/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/Dialog.kt b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/Dialog.kt index 635f35918e..080797e28b 100644 --- a/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/Dialog.kt +++ b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/Dialog.kt @@ -291,6 +291,9 @@ object DialogDefaults { val onConfirmButtonClick: () -> Unit, val confirmButtonText: String, val buttonSize: ButtonSize = defaultButtonSize, + // When true the confirm button renders with a destructive (red) label on a secondary fill, + // instead of the default primary style. For dialogs where the confirm action is destructive. + val confirmButtonRedText: Boolean = false, ) : DialogStyle() /** @@ -368,6 +371,7 @@ private fun HedvigDialogContent( dismissButtonText = style.dismissButtonText, onConfirmButtonClick = style.onConfirmButtonClick, confirmButtonText = style.confirmButtonText, + confirmButtonRedText = style.confirmButtonRedText, ) } @@ -377,6 +381,7 @@ private fun HedvigDialogContent( dismissButtonText = style.dismissButtonText, onConfirmButtonClick = style.onConfirmButtonClick, confirmButtonText = style.confirmButtonText, + confirmButtonRedText = style.confirmButtonRedText, ) } } @@ -465,6 +470,7 @@ private fun SmallHorizontalPreferringButtons( dismissButtonText: String, onConfirmButtonClick: () -> Unit, confirmButtonText: String, + confirmButtonRedText: Boolean = false, ) { val textMeasurer = rememberTextMeasurer(cacheSize = 2) val textStyle = LocalTextStyle.current @@ -477,13 +483,21 @@ private fun SmallHorizontalPreferringButtons( buttonStyle = ButtonDefaults.ButtonStyle.Secondary, buttonSize = ButtonDefaults.ButtonSize.Medium, ) - HedvigButton( - onClick = onConfirmButtonClick, - text = confirmButtonText, - enabled = true, - buttonStyle = ButtonDefaults.ButtonStyle.Primary, - buttonSize = ButtonDefaults.ButtonSize.Medium, - ) + if (confirmButtonRedText) { + HedvigSecondaryRedTextButton( + onClick = onConfirmButtonClick, + text = confirmButtonText, + buttonSize = ButtonDefaults.ButtonSize.Medium, + ) + } else { + HedvigButton( + onClick = onConfirmButtonClick, + text = confirmButtonText, + enabled = true, + buttonStyle = ButtonDefaults.ButtonStyle.Primary, + buttonSize = ButtonDefaults.ButtonSize.Medium, + ) + } }, ) { measurables, constraints -> val spaceBetween = 8.dp @@ -531,16 +545,26 @@ private fun BigVerticalButtons( dismissButtonText: String, onConfirmButtonClick: () -> Unit, confirmButtonText: String, + confirmButtonRedText: Boolean = false, ) { Column { - HedvigButton( - modifier = Modifier.fillMaxWidth(), - onClick = onConfirmButtonClick, - text = confirmButtonText, - enabled = true, - buttonStyle = ButtonDefaults.ButtonStyle.Primary, - buttonSize = ButtonDefaults.ButtonSize.Large, - ) + if (confirmButtonRedText) { + HedvigSecondaryRedTextButton( + modifier = Modifier.fillMaxWidth(), + onClick = onConfirmButtonClick, + text = confirmButtonText, + buttonSize = ButtonDefaults.ButtonSize.Large, + ) + } else { + HedvigButton( + modifier = Modifier.fillMaxWidth(), + onClick = onConfirmButtonClick, + text = confirmButtonText, + enabled = true, + buttonStyle = ButtonDefaults.ButtonStyle.Primary, + buttonSize = ButtonDefaults.ButtonSize.Large, + ) + } Spacer(Modifier.height(8.dp)) HedvigButton( modifier = Modifier.fillMaxWidth(), diff --git a/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/DraftClaimDialog.kt b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/DraftClaimDialog.kt new file mode 100644 index 0000000000..2e260ee7ee --- /dev/null +++ b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/DraftClaimDialog.kt @@ -0,0 +1,59 @@ +package com.hedvig.android.design.system.hedvig + +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import com.hedvig.android.design.system.hedvig.DialogDefaults.DialogStyle.Buttons +import hedvig.resources.RESUME_CLAIM_DRAFT_ALERT_BODY +import hedvig.resources.RESUME_CLAIM_DRAFT_ALERT_CONTINUE +import hedvig.resources.RESUME_CLAIM_DRAFT_ALERT_START_NEW +import hedvig.resources.RESUME_CLAIM_DRAFT_ALERT_TITLE +import hedvig.resources.Res +import org.jetbrains.compose.resources.stringResource + +@Composable +fun DraftClaimDialog( + onDismissRequest: () -> Unit, + onContinueDraft: () -> Unit, + onStartNewClaim: () -> Unit, + modifier: Modifier = Modifier, +) { + // Two-button dialog: left "Continue draft" (safe), right "Start new claim" (destructive, red). + // SMALL keeps them side by side when they fit and stacks them otherwise. Dismissing via scrim or + // back does nothing beyond closing; both actions are wired to the explicit buttons. + HedvigDialog( + onDismissRequest = onDismissRequest, + modifier = modifier, + style = Buttons( + onDismissRequest = onContinueDraft, + dismissButtonText = stringResource(Res.string.RESUME_CLAIM_DRAFT_ALERT_CONTINUE), + onConfirmButtonClick = onStartNewClaim, + confirmButtonText = stringResource(Res.string.RESUME_CLAIM_DRAFT_ALERT_START_NEW), + confirmButtonRedText = true, + ), + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + HedvigText( + text = stringResource(Res.string.RESUME_CLAIM_DRAFT_ALERT_TITLE), + textAlign = TextAlign.Center, + ) + HedvigText( + text = stringResource(Res.string.RESUME_CLAIM_DRAFT_ALERT_BODY), + textAlign = TextAlign.Center, + color = HedvigTheme.colorScheme.textSecondary, + ) + } + } +} + +@HedvigPreview +@Composable +private fun PreviewDraftClaimDialog() { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + DraftClaimDialog({}, {}, {}) + } + } +} diff --git a/app/feature/feature-chat/build.gradle.kts b/app/feature/feature-chat/build.gradle.kts index 0d9a9f08bd..99570b329b 100644 --- a/app/feature/feature-chat/build.gradle.kts +++ b/app/feature/feature-chat/build.gradle.kts @@ -46,6 +46,7 @@ dependencies { implementation(projects.coreMarkdown) implementation(projects.coreResources) implementation(projects.dataChat) + implementation(projects.dataClaimIntent) implementation(projects.designSystemHedvig) implementation(projects.featureFlags) implementation(projects.languageCore) diff --git a/app/feature/feature-chat/src/main/kotlin/com/hedvig/android/feature/chat/inbox/InboxDestination.kt b/app/feature/feature-chat/src/main/kotlin/com/hedvig/android/feature/chat/inbox/InboxDestination.kt index 390005cba1..46d9a5e385 100644 --- a/app/feature/feature-chat/src/main/kotlin/com/hedvig/android/feature/chat/inbox/InboxDestination.kt +++ b/app/feature/feature-chat/src/main/kotlin/com/hedvig/android/feature/chat/inbox/InboxDestination.kt @@ -24,6 +24,9 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -44,6 +47,7 @@ import com.hedvig.android.compose.ui.preview.TripleBooleanCollectionPreviewParam import com.hedvig.android.compose.ui.preview.TripleCase import com.hedvig.android.design.system.hedvig.ButtonDefaults import com.hedvig.android.design.system.hedvig.DividerPosition +import com.hedvig.android.design.system.hedvig.DraftClaimDialog import com.hedvig.android.design.system.hedvig.EmptyState import com.hedvig.android.design.system.hedvig.EmptyStateDefaults import com.hedvig.android.design.system.hedvig.HedvigBottomSheet @@ -108,7 +112,7 @@ internal fun InboxDestination( navigateUp: () -> Unit, onConversationClick: (id: String) -> Unit, onNavigateToNewConversation: () -> Unit, - navigateToClaimChat: () -> Unit, + navigateToClaimChat: (resumeClaim: Boolean) -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() InboxScreen( @@ -128,10 +132,24 @@ private fun InboxScreen( onConversationClick: (id: String) -> Unit, onNavigateToNewConversation: () -> Unit, reload: () -> Unit, - navigateToClaimChat: () -> Unit, + navigateToClaimChat: (resumeClaim: Boolean) -> Unit, ) { val newChatSelectBottomSheetState = rememberHedvigBottomSheetState() val startClaimBottomSheetState = rememberHedvigBottomSheetState() + var showDraftClaimDialog by remember { mutableStateOf(false) } + if (showDraftClaimDialog) { + DraftClaimDialog( + onDismissRequest = { showDraftClaimDialog = false }, + onContinueDraft = { + showDraftClaimDialog = false + navigateToClaimChat(true) + }, + onStartNewClaim = { + showDraftClaimDialog = false + startClaimBottomSheetState.show(Unit) + }, + ) + } HedvigBottomSheet( newChatSelectBottomSheetState, content = { @@ -142,7 +160,11 @@ private fun InboxScreen( }, onStartNewClaim = { newChatSelectBottomSheetState.dismiss() - startClaimBottomSheetState.show(Unit) + if ((uiState as? InboxUiState.Success)?.hasDraftClaim == true) { + showDraftClaimDialog = true + } else { + startClaimBottomSheetState.show(Unit) + } }, dismiss = { newChatSelectBottomSheetState.dismiss() @@ -154,7 +176,7 @@ private fun InboxScreen( state = startClaimBottomSheetState, navigateToClaimChat = { startClaimBottomSheetState.dismiss() - navigateToClaimChat() + navigateToClaimChat(false) }, ) Surface( @@ -479,6 +501,7 @@ private fun EmptyInboxSuccessScreenPreview( InboxUiState.Success( listOf(), case, + hasDraftClaim = false, ), {}, {}, @@ -518,6 +541,7 @@ private fun InboxSuccessScreenPreview( mockInboxConversationLegacy, ), newConversationButtonAvailable = case, + hasDraftClaim = false, ), {}, {}, diff --git a/app/feature/feature-chat/src/main/kotlin/com/hedvig/android/feature/chat/inbox/InboxViewModel.kt b/app/feature/feature-chat/src/main/kotlin/com/hedvig/android/feature/chat/inbox/InboxViewModel.kt index b66564ecd5..749e24b16d 100644 --- a/app/feature/feature-chat/src/main/kotlin/com/hedvig/android/feature/chat/inbox/InboxViewModel.kt +++ b/app/feature/feature-chat/src/main/kotlin/com/hedvig/android/feature/chat/inbox/InboxViewModel.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import com.hedvig.android.core.common.di.ActivityRetainedScope import com.hedvig.android.core.common.di.HedvigViewModel +import com.hedvig.android.data.claimintent.GetResumableClaimIntentUseCase import com.hedvig.android.feature.chat.data.GetAllConversationsUseCase import com.hedvig.android.feature.chat.model.InboxConversation import com.hedvig.android.featureflags.FeatureManager @@ -19,20 +20,23 @@ import com.hedvig.android.molecule.public.MoleculeViewModel import dev.zacsweers.metro.Inject import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first @Inject @HedvigViewModel(ActivityRetainedScope::class) internal class InboxViewModel( getAllConversationsUseCase: GetAllConversationsUseCase, featureManager: FeatureManager, + getResumableClaimIntentUseCase: GetResumableClaimIntentUseCase, ) : MoleculeViewModel( initialState = InboxUiState.Loading, - presenter = InboxPresenter(getAllConversationsUseCase, featureManager), + presenter = InboxPresenter(getAllConversationsUseCase, featureManager, getResumableClaimIntentUseCase), ) internal class InboxPresenter( private val getAllConversationsUseCase: GetAllConversationsUseCase, private val featureManager: FeatureManager, + private val getResumableClaimIntentUseCase: GetResumableClaimIntentUseCase, ) : MoleculePresenter { @Composable override fun MoleculePresenterScope.present(lastState: InboxUiState): InboxUiState { @@ -53,6 +57,15 @@ internal class InboxPresenter( if (currentState !is InboxUiState.Success) { currentState = InboxUiState.Loading } + val hasDraftClaim = if (featureManager.isFeatureEnabled(Feature.ENABLE_CLAIM_INTENT_RESUME).first()) { + getResumableClaimIntentUseCase.invoke().fold( + // A failed draft lookup must not block starting a claim; treat it as no draft. + ifLeft = { false }, + ifRight = { it != null }, + ) + } else { + false + } combine( getAllConversationsUseCase.invoke(), featureManager.isFeatureEnabled(Feature.ENABLE_NEW_CONVERSATION_FROM_INBOX), @@ -69,6 +82,7 @@ internal class InboxPresenter( currentState = InboxUiState.Success( conversations, newChatButtonAvailable, + hasDraftClaim, ) }, ) @@ -86,6 +100,7 @@ internal sealed interface InboxUiState { data class Success( val inboxConversations: List, val newConversationButtonAvailable: Boolean, + val hasDraftClaim: Boolean, ) : InboxUiState } diff --git a/app/feature/feature-chat/src/main/kotlin/com/hedvig/android/feature/chat/navigation/CbmChatEntries.kt b/app/feature/feature-chat/src/main/kotlin/com/hedvig/android/feature/chat/navigation/CbmChatEntries.kt index 954b37d793..d9395543f7 100644 --- a/app/feature/feature-chat/src/main/kotlin/com/hedvig/android/feature/chat/navigation/CbmChatEntries.kt +++ b/app/feature/feature-chat/src/main/kotlin/com/hedvig/android/feature/chat/navigation/CbmChatEntries.kt @@ -22,7 +22,7 @@ fun EntryProviderScope.cbmChatEntries( onNavigateToClaimDetails: (claimId: String) -> Unit, onNavigateToImageViewer: (imageUrl: String, cacheKey: String) -> Unit, onNavigateToNewConversation: () -> Unit, - navigateToClaimChat: () -> Unit, + navigateToClaimChat: (resumeClaim: Boolean) -> Unit, backstack: Backstack, ) { entry { diff --git a/app/feature/feature-claim-chat/build.gradle.kts b/app/feature/feature-claim-chat/build.gradle.kts index 41413e0056..a379a3dc2b 100644 --- a/app/feature/feature-claim-chat/build.gradle.kts +++ b/app/feature/feature-claim-chat/build.gradle.kts @@ -46,6 +46,7 @@ kotlin { implementation(projects.coreResources) implementation(projects.coreUiData) implementation(projects.designSystemHedvig) + implementation(projects.featureFlags) implementation(projects.languageCore) implementation(projects.moleculePublic) implementation(projects.navigationCore) diff --git a/app/feature/feature-claim-chat/src/androidMain/kotlin/com/hedvig/feature/claim/chat/data/AudioRecordingManager.kt b/app/feature/feature-claim-chat/src/androidMain/kotlin/com/hedvig/feature/claim/chat/data/AudioRecordingManager.kt index f1e086ab48..63eead9f66 100644 --- a/app/feature/feature-claim-chat/src/androidMain/kotlin/com/hedvig/feature/claim/chat/data/AudioRecordingManager.kt +++ b/app/feature/feature-claim-chat/src/androidMain/kotlin/com/hedvig/feature/claim/chat/data/AudioRecordingManager.kt @@ -86,7 +86,7 @@ internal class AndroidAudioRecordingManager( if (!file.exists()) { onStateUpdate( AudioRecordingStepState.AudioRecording.Playback( - filePath = filePath, + audioPath = AudioPath.FilePath(filePath), isPlaying = false, isPrepared = false, hasError = true, @@ -100,7 +100,7 @@ internal class AndroidAudioRecordingManager( setOnPreparedListener { onStateUpdate( AudioRecordingStepState.AudioRecording.Playback( - filePath = filePath, + audioPath = AudioPath.FilePath(filePath), isPlaying = false, isPrepared = true, hasError = false, diff --git a/app/feature/feature-claim-chat/src/androidMain/kotlin/com/hedvig/feature/claim/chat/navigation/ClaimChatEntries.kt b/app/feature/feature-claim-chat/src/androidMain/kotlin/com/hedvig/feature/claim/chat/navigation/ClaimChatEntries.kt index 2c163cf3cc..eec39bc248 100644 --- a/app/feature/feature-claim-chat/src/androidMain/kotlin/com/hedvig/feature/claim/chat/navigation/ClaimChatEntries.kt +++ b/app/feature/feature-claim-chat/src/androidMain/kotlin/com/hedvig/feature/claim/chat/navigation/ClaimChatEntries.kt @@ -21,6 +21,7 @@ import kotlinx.serialization.Serializable data class ClaimChatKey( val isDevelopmentFlow: Boolean = false, val messageId: String? = null, + val resumeClaim: Boolean = false, ) : HedvigNavKey @Serializable @@ -58,6 +59,7 @@ fun EntryProviderScope.claimChatEntries( ) { entry { key -> ClaimChatDestination( + resumeClaim = key.resumeClaim, isDevelopmentFlow = key.isDevelopmentFlow, shouldShowRequestPermissionRationale = shouldShowRequestPermissionRationale, openAppSettings = openAppSettings, diff --git a/app/feature/feature-claim-chat/src/commonMain/graphql/FragmentClaimIntent.graphql b/app/feature/feature-claim-chat/src/commonMain/graphql/FragmentClaimIntent.graphql index 14e8d0d94e..f9c137b0df 100644 --- a/app/feature/feature-claim-chat/src/commonMain/graphql/FragmentClaimIntent.graphql +++ b/app/feature/feature-claim-chat/src/commonMain/graphql/FragmentClaimIntent.graphql @@ -1,6 +1,8 @@ fragment ClaimIntentFragment on ClaimIntent { id progress + displayName + resumable currentStep { id text @@ -14,6 +16,15 @@ fragment ClaimIntentFragment on ClaimIntent { id submittedAt } + previousSteps { + id + text + hint + content { + ...ClaimIntentStepContentFragment + } + isRegrettable + } } fragment ClaimIntentStepContentFragment on ClaimIntentStepContent { @@ -44,6 +55,7 @@ fragment FormFragment on ClaimIntentStepContentForm { value } defaultValues + currentValues minValue maxValue searchData { @@ -63,6 +75,7 @@ fragment ContentSelectFragment on ClaimIntentStepContentSelect { isSkippable style defaultSelectedId + currentSelectedId } fragment TaskFragment on ClaimIntentStepContentTask { @@ -75,11 +88,18 @@ fragment AudioRecordingFragment on ClaimIntentStepContentAudioRecording { isSkippable freeTextMinLength freeTextMaxLength + currentAudioUrl + currentFreeText } fragment FileUploadFragment on ClaimIntentStepContentFileUpload { uploadUri isSkippable + currentFiles { + url + contentType + fileName + } } fragment SummaryFragment on ClaimIntentStepContentSummary { diff --git a/app/feature/feature-claim-chat/src/commonMain/graphql/ResumeClaimQuery.graphql b/app/feature/feature-claim-chat/src/commonMain/graphql/ResumeClaimQuery.graphql new file mode 100644 index 0000000000..61e6412c55 --- /dev/null +++ b/app/feature/feature-claim-chat/src/commonMain/graphql/ResumeClaimQuery.graphql @@ -0,0 +1,7 @@ +query ResumeClaim { + currentMember { + resumableClaimIntent { + ...ClaimIntentFragment + } + } +} diff --git a/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/ClaimChatViewModel.kt b/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/ClaimChatViewModel.kt index 97e4b68f97..9654bcc1c6 100644 --- a/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/ClaimChatViewModel.kt +++ b/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/ClaimChatViewModel.kt @@ -2,6 +2,7 @@ package com.hedvig.feature.claim.chat import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf @@ -15,6 +16,8 @@ import com.hedvig.android.core.common.di.ActivityRetainedScope import com.hedvig.android.core.common.di.HedvigViewModel import com.hedvig.android.core.fileupload.FileService import com.hedvig.android.core.uidata.UiFile +import com.hedvig.android.featureflags.FeatureManager +import com.hedvig.android.featureflags.flags.Feature import com.hedvig.android.logger.logcat import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope @@ -34,6 +37,7 @@ import com.hedvig.feature.claim.chat.data.FormSubmissionData.FieldToSubmit import com.hedvig.feature.claim.chat.data.FreeTextErrorType.TooShort import com.hedvig.feature.claim.chat.data.GetClaimIntentUseCase import com.hedvig.feature.claim.chat.data.RegretStepUseCase +import com.hedvig.feature.claim.chat.data.ResumeClaimUseCase import com.hedvig.feature.claim.chat.data.SkipStepUseCase import com.hedvig.feature.claim.chat.data.StartClaimIntentUseCase import com.hedvig.feature.claim.chat.data.StepContent @@ -147,7 +151,6 @@ internal sealed interface ClaimChatUiState { val claimIntentId: ClaimIntentId, val steps: List, val currentStep: ClaimIntentStep?, - val freeText: String?, val outcome: ClaimIntentOutcome?, val errorSubmittingStep: ClaimChatErrorMessage?, val currentContinueButtonLoading: Boolean = false, @@ -157,6 +160,9 @@ internal sealed interface ClaimChatUiState { val stepsWithShownAnimations: List, val progress: Float?, val searchQuery: SearchObject?, + val title: String?, + val isResumable: Boolean, + val resumeClaimEnabled: Boolean, ) : ClaimChatUiState } @@ -164,6 +170,7 @@ internal sealed interface ClaimChatUiState { @HedvigViewModel(ActivityRetainedScope::class) internal class ClaimChatViewModel( @Assisted developmentFlow: Boolean, + @Assisted resumeClaim: Boolean, startClaimIntentUseCase: StartClaimIntentUseCase, getClaimIntentUseCase: GetClaimIntentUseCase, submitTaskUseCase: SubmitTaskUseCase, @@ -177,6 +184,8 @@ internal class ClaimChatViewModel( regretStepUseCase: RegretStepUseCase, formFieldSearchUseCase: FormFieldSearchUseCase, fileService: FileService, + resumeClaimUseCase: ResumeClaimUseCase, + featureManager: FeatureManager, ) : MoleculeViewModel( ClaimChatUiState.Initializing, ClaimChatPresenter( @@ -194,6 +203,9 @@ internal class ClaimChatViewModel( fileService, regretStepUseCase, formFieldSearchUseCase, + resumeClaim, + resumeClaimUseCase, + featureManager, ), ) { override fun onCleared() { @@ -217,6 +229,9 @@ internal class ClaimChatPresenter( private val fileService: FileService, private val regretStepUseCase: RegretStepUseCase, private val formFieldSearchUseCase: FormFieldSearchUseCase, + private val resumeClaim: Boolean, + private val resumeClaimUseCase: ResumeClaimUseCase, + private val featureManager: FeatureManager, ) : MoleculePresenter { @Composable override fun MoleculePresenterScope.present(lastState: ClaimChatUiState): ClaimChatUiState { @@ -240,7 +255,6 @@ internal class ClaimChatPresenter( var currentContinueButtonLoading by remember { mutableStateOf(false) } var currentSkipButtonLoading by remember { mutableStateOf(false) } var errorSubmittingStep by remember { mutableStateOf(null) } - var freeText by remember { mutableStateOf(null) } var showConfirmEditDialogForStep by remember { mutableStateOf(null) } var progress by remember { mutableStateOf( @@ -248,6 +262,19 @@ internal class ClaimChatPresenter( ?: 0f, ) } + var title by remember { mutableStateOf((lastState as? ClaimChatUiState.ClaimChat)?.title) } + var isResumable by remember { + mutableStateOf((lastState as? ClaimChatUiState.ClaimChat)?.isResumable ?: false) + } + val updateIntentMetadata: (ClaimIntent) -> Unit = { intent -> + progress = intent.progress + // Keep the previous title when a step comes back without one, matching iOS. + title = intent.displayName ?: title + isResumable = intent.resumable + } + val resumeClaimEnabled by remember { + featureManager.isFeatureEnabled(Feature.ENABLE_CLAIM_INTENT_RESUME) + }.collectAsState(initial = false) val stepsWithShownAnimations = remember { mutableStateListOf() } val setOutcome: (ClaimIntentOutcome) -> Unit = { outcome = it } @@ -256,32 +283,72 @@ internal class ClaimChatPresenter( if (initializing) { LaunchedEffect(Unit) { - startClaimIntentUseCase - .invoke(developmentFlow) - .fold( - ifLeft = { - initializing = false - failedToStart = true - }, - ifRight = { claimIntent -> - Snapshot.withMutableSnapshot { + val isResumingClaim = resumeClaim + if (isResumingClaim) { + resumeClaimUseCase + .invoke() + .fold( + ifLeft = { initializing = false - failedToStart = false - claimIntentId = claimIntent.id - steps.clear() - progress = claimIntent.progress - when (val next = claimIntent.next) { - is ClaimIntent.Next.Outcome -> { - outcome = next.claimIntentOutcome + failedToStart = true + }, + ifRight = { claimIntent -> + if (claimIntent == null) { + initializing = false + failedToStart = true + } else { + Snapshot.withMutableSnapshot { + initializing = false + failedToStart = false + claimIntentId = claimIntent.id + steps.clear() + steps.addAll( + claimIntent.previousSteps.filter { + it.stepContent !is StepContent.Task + }, + ) + updateIntentMetadata(claimIntent) + when (val next = claimIntent.next) { + is ClaimIntent.Next.Outcome -> { + outcome = next.claimIntentOutcome + } + + is ClaimIntent.Next.Step -> { + steps.add(next.claimIntentStep) + } + } } + } + }, + ) + } else { + startClaimIntentUseCase + .invoke(developmentFlow) + .fold( + ifLeft = { + initializing = false + failedToStart = true + }, + ifRight = { claimIntent -> + Snapshot.withMutableSnapshot { + initializing = false + failedToStart = false + claimIntentId = claimIntent.id + steps.clear() + updateIntentMetadata(claimIntent) + when (val next = claimIntent.next) { + is ClaimIntent.Next.Outcome -> { + outcome = next.claimIntentOutcome + } - is ClaimIntent.Next.Step -> { - steps.add(next.claimIntentStep) + is ClaimIntent.Next.Step -> { + steps.add(next.claimIntentStep) + } } } - } - }, - ) + }, + ) + } } } @@ -305,7 +372,8 @@ internal class ClaimChatPresenter( steps, setOutcome, claimIntent, - ) { progress = it } + updateIntentMetadata, + ) }, ) @@ -388,7 +456,8 @@ internal class ClaimChatPresenter( steps, setOutcome, claimIntent, - ) { progress = it } + updateIntentMetadata, + ) }, ) } @@ -424,14 +493,18 @@ internal class ClaimChatPresenter( steps, setOutcome, claimIntent, - ) { progress = it } + updateIntentMetadata, + ) }, ) } } is ClaimChatEvent.AudioRecording.SubmitTextInput -> { - val freeTextInput = freeText ?: return@CollectEvents + val recordingState = steps.find { it.id == event.id } + ?.stepContent.let { it as? StepContent.AudioRecording } + ?.recordingState as? FreeTextDescription + val freeTextInput = recordingState?.freeText ?: return@CollectEvents currentContinueButtonLoading = true launch { submitAudioRecordingUseCase @@ -449,7 +522,8 @@ internal class ClaimChatPresenter( steps, setOutcome, claimIntent, - ) { progress = it } + updateIntentMetadata, + ) }, ) } @@ -479,13 +553,7 @@ internal class ClaimChatPresenter( } is ClaimChatEvent.AudioRecording.SwitchToFreeText -> { - val currentContent = currentStep?.stepContent as? StepContent.AudioRecording - ?: return@CollectEvents - val textTooShort = freeText?.length?.let { - currentContent.freeTextMinLength > it - } ?: true steps.updateStepWithSuccess(event.id) { step, content -> - val canSubmit = !currentContinueButtonLoading && !freeText.isNullOrEmpty() && !textTooShort showFreeTextOverlay = FreeTextRestrictions( content.freeTextMinLength, content.freeTextMaxLength, @@ -494,7 +562,8 @@ internal class ClaimChatPresenter( stepContent = content.copy( recordingState = FreeTextDescription( errorType = null, - canSubmit = canSubmit, + canSubmit = false, + freeText = null, ), ), ) @@ -595,7 +664,8 @@ internal class ClaimChatPresenter( steps, setOutcome, claimIntent, - ) { progress = it } + updateIntentMetadata, + ) }, ) } @@ -656,7 +726,8 @@ internal class ClaimChatPresenter( steps, setOutcome, claimIntent, - ) { progress = it } + updateIntentMetadata, + ) }, ) } @@ -690,11 +761,11 @@ internal class ClaimChatPresenter( null }, canSubmit = canSubmit, + freeText = event.text, ), ), ) } - freeText = event.text } } @@ -715,7 +786,8 @@ internal class ClaimChatPresenter( steps, setOutcome, claimIntent, - ) { progress = it } + updateIntentMetadata, + ) }, ) } @@ -743,7 +815,6 @@ internal class ClaimChatPresenter( val index = steps.indexOf(stepToUpdate) if (index >= 0) { steps.subList(index, steps.size).clear() - if (steps.none { it.stepContent is StepContent.AudioRecording }) freeText = null } currentContinueButtonLoading = false currentSkipButtonLoading = false @@ -751,7 +822,8 @@ internal class ClaimChatPresenter( steps, setOutcome, claimIntent, - ) { progress = it } + updateIntentMetadata, + ) }, ) } @@ -855,7 +927,8 @@ internal class ClaimChatPresenter( steps, setOutcome, claimIntent, - ) { progress = it } + updateIntentMetadata, + ) }, ) } @@ -950,7 +1023,6 @@ internal class ClaimChatPresenter( currentStep = currentStep, outcome = outcome, showFreeTextOverlay = showFreeTextOverlay, - freeText = freeText, errorSubmittingStep = errorSubmittingStep, currentContinueButtonLoading = currentContinueButtonLoading, currentSkipButtonLoading = currentSkipButtonLoading, @@ -958,6 +1030,9 @@ internal class ClaimChatPresenter( stepsWithShownAnimations = stepsWithShownAnimations, progress = progress, searchQuery = searchQuery, + title = title, + isResumable = isResumable, + resumeClaimEnabled = resumeClaimEnabled, ) else -> error("") @@ -1034,10 +1109,10 @@ private fun handleNext( steps: SnapshotStateList, setOutcome: (outcome: ClaimIntentOutcome) -> Unit, intent: ClaimIntent, - setProgress: (Float?) -> Unit, + updateIntentMetadata: (ClaimIntent) -> Unit, ) { val next = intent.next - setProgress(intent.progress) + updateIntentMetadata(intent) when (next) { is ClaimIntent.Next.Outcome -> { setOutcome(next.claimIntentOutcome) diff --git a/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/ClaimIntent.kt b/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/ClaimIntent.kt index 6df1fd1eed..8d4cc351bf 100644 --- a/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/ClaimIntent.kt +++ b/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/ClaimIntent.kt @@ -15,6 +15,9 @@ internal data class ClaimIntent( val id: ClaimIntentId, val next: Next, val progress: Float?, + val displayName: String?, + val resumable: Boolean, + val previousSteps: List, ) { sealed interface Next { val step: Step? @@ -62,7 +65,9 @@ internal sealed interface StepContent { val uploadUri: String, override val isSkippable: Boolean, val localFiles: List, - ) : StepContent + ) : StepContent { + data class RemoteFile(val url: String, val contentType: String, val fileName: String) + } data class Task( val descriptions: List, @@ -186,6 +191,7 @@ sealed interface AudioRecordingStepState { val errorType: FreeTextErrorType?, val canSubmit: Boolean, val hasError: Boolean = false, + val freeText: String? = null, ) : AudioRecordingStepState sealed interface AudioRecording : AudioRecordingStepState { @@ -198,7 +204,7 @@ sealed interface AudioRecordingStepState { ) : AudioRecording data class Playback( - val filePath: String, + val audioPath: AudioPath, val isPlaying: Boolean, val isPrepared: Boolean, val hasError: Boolean, @@ -206,6 +212,12 @@ sealed interface AudioRecordingStepState { } } +sealed interface AudioPath { + data class FilePath(val filePath: String) : AudioPath + + data class RemoteUrl(val remoteUrl: String) : AudioPath +} + sealed interface FreeTextErrorType { data class TooShort(val minLength: Int) : FreeTextErrorType } diff --git a/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/ClaimIntentExt.kt b/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/ClaimIntentExt.kt index 9848835497..5eac5ccd4a 100644 --- a/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/ClaimIntentExt.kt +++ b/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/ClaimIntentExt.kt @@ -4,6 +4,7 @@ import arrow.core.raise.Raise import arrow.core.raise.context.raise import com.hedvig.android.core.common.ErrorMessage import com.hedvig.android.core.locale.CommonLocale +import com.hedvig.android.core.uidata.UiFile import com.hedvig.android.design.system.hedvig.DatePickerUiState import com.hedvig.android.logger.logcat import com.hedvig.android.shared.partners.deflect.DeflectData @@ -56,6 +57,11 @@ internal fun ClaimIntentFragment.toClaimIntent(locale: CommonLocale): ClaimInten else -> error("ClaimIntentFragment contained null currentStep and null outcome") }, progress = progress?.toFloat(), + displayName = displayName, + resumable = resumable, + previousSteps = previousSteps.map { + it.toClaimIntentStep(locale) + }, ) } @@ -70,6 +76,17 @@ private fun ClaimIntentFragment.CurrentStep.toClaimIntentStep(locale: CommonLoca ) } +context(raise: Raise) +private fun ClaimIntentFragment.PreviousStep.toClaimIntentStep(locale: CommonLocale): ClaimIntentStep { + return ClaimIntentStep( + id = StepId(id), + text = text, + stepContent = this.content.toStepContent(locale), + isRegrettable = this.isRegrettable, + hint = hint, + ) +} + context(raise: Raise) private fun ClaimIntentStepContentFragment.toStepContent(locale: CommonLocale): StepContent { return when (this) { @@ -83,7 +100,7 @@ private fun ClaimIntentStepContentFragment.toStepContent(locale: CommonLocale): is ContentSelectFragment -> { StepContent.ContentSelect( options = options.toOptions(), - selectedOptionId = defaultSelectedId, + selectedOptionId = currentSelectedId ?: defaultSelectedId, isSkippable = isSkippable, style = when (style) { ClaimIntentStepContentSelectStyle.PILL -> StepContent.ContentSelectStyle.PILL @@ -102,10 +119,30 @@ private fun ClaimIntentStepContentFragment.toStepContent(locale: CommonLocale): } is AudioRecordingFragment -> { + val audioUrl = this.currentAudioUrl + val freeText = this.currentFreeText + val recordingState = if (audioUrl != null) { + AudioRecordingStepState.AudioRecording.Playback( + audioPath = AudioPath.RemoteUrl(audioUrl), + isPlaying = false, + // A resumed remote recording has no local MediaPlayer to prepare; the remote audio player + // handles its own buffering, so the playback UI can show immediately. + isPrepared = true, + hasError = false, + ) + } else if (freeText != null) { + AudioRecordingStepState.FreeTextDescription( + errorType = null, + canSubmit = true, + freeText = freeText, + ) + } else { + AudioRecordingStepState.AudioRecording.NotRecording + } StepContent.AudioRecording( uploadUri = uploadUri, isSkippable = isSkippable, - recordingState = AudioRecordingStepState.AudioRecording.NotRecording, + recordingState = recordingState, freeTextMinLength = freeTextMinLength, freeTextMaxLength = freeTextMaxLength, ) @@ -115,7 +152,15 @@ private fun ClaimIntentStepContentFragment.toStepContent(locale: CommonLocale): StepContent.FileUpload( uploadUri = uploadUri, isSkippable = isSkippable, - localFiles = emptyList(), + localFiles = this.currentFiles?.map { + UiFile( + name = it.fileName, + localPath = null, + url = it.url, + mimeType = it.contentType, + id = it.url, + ) + } ?: emptyList(), ) } @@ -207,12 +252,17 @@ private fun List.toOptions(): List) private fun List.toFields(locale: CommonLocale): List { return this.map { field -> + val defaultValues = if (field.currentValues.isNotEmpty()) { + field.currentValues.toFieldOptions(field.options) + } else { + field.defaultValues.toFieldOptions(field.options) + } StepContent.Form.Field( id = FieldId(field.id), isRequired = field.isRequired, suffix = field.suffix, title = field.title, - defaultValues = field.defaultValues.toFieldOptions(field.options), + defaultValues = defaultValues, maxValue = field.maxValue, minValue = field.minValue, type = when (field.type) { @@ -260,12 +310,12 @@ private fun List.toFields(locale: CommonLocale): List { DatePickerUiState( locale = locale, - initiallySelectedDate = field.defaultValues.getOrNull(0)?.let { LocalDate.parse(it) }, + initiallySelectedDate = defaultValues.getOrNull(0)?.let { LocalDate.parse(it.value) }, minDate = field.minValue?.let { LocalDate.parse(it) } ?: LocalDate(1900, 1, 1), maxDate = field.maxValue?.let { LocalDate.parse(it) } ?: LocalDate(2100, 1, 1), ) diff --git a/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/ResumeClaimUseCase.kt b/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/ResumeClaimUseCase.kt new file mode 100644 index 0000000000..20d0423c82 --- /dev/null +++ b/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/ResumeClaimUseCase.kt @@ -0,0 +1,39 @@ +package com.hedvig.feature.claim.chat.data + +import arrow.core.Either +import arrow.core.raise.either +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.cache.normalized.FetchPolicy +import com.apollographql.apollo.cache.normalized.fetchPolicy +import com.hedvig.android.apollo.safeExecute +import com.hedvig.android.core.common.di.AppScope +import com.hedvig.android.language.LanguageService +import com.hedvig.android.logger.logcat +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import octopus.ResumeClaimQuery + +@SingleIn(AppScope::class) +@Inject +internal class ResumeClaimUseCase( + private val apolloClient: ApolloClient, + private val languageService: LanguageService, +) { + suspend fun invoke(): Either { + return either { + apolloClient + .query( + ResumeClaimQuery(), + ) + .fetchPolicy(FetchPolicy.NetworkOnly) + .safeExecute() + .mapLeft { error -> + logcat(operationError = error) { "ResumeClaimUseCase failed with $error" } + ClaimChatErrorMessage.GeneralError + } + .bind() + .currentMember.resumableClaimIntent + ?.toClaimIntent(languageService.getLocale()) + } + } +} diff --git a/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/ui/ClaimChatDestination.kt b/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/ui/ClaimChatDestination.kt index b998d9d84f..11097a3019 100644 --- a/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/ui/ClaimChatDestination.kt +++ b/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/ui/ClaimChatDestination.kt @@ -83,11 +83,31 @@ import com.hedvig.android.design.system.hedvig.icon.HedvigIcons import com.hedvig.android.logger.LogPriority import com.hedvig.android.logger.logcat import com.hedvig.feature.claim.chat.ClaimChatEvent -import com.hedvig.feature.claim.chat.ClaimChatEvent.* -import com.hedvig.feature.claim.chat.ClaimChatEvent.AudioRecording.* +import com.hedvig.feature.claim.chat.ClaimChatEvent.AddToShownAnimations +import com.hedvig.feature.claim.chat.ClaimChatEvent.AudioRecording.RedoRecording +import com.hedvig.feature.claim.chat.ClaimChatEvent.AudioRecording.StartRecording +import com.hedvig.feature.claim.chat.ClaimChatEvent.AudioRecording.StopRecording +import com.hedvig.feature.claim.chat.ClaimChatEvent.AudioRecording.SubmitAudioFile +import com.hedvig.feature.claim.chat.ClaimChatEvent.AudioRecording.SubmitTextInput +import com.hedvig.feature.claim.chat.ClaimChatEvent.AudioRecording.SwitchToAudioRecording +import com.hedvig.feature.claim.chat.ClaimChatEvent.AudioRecording.SwitchToFreeText +import com.hedvig.feature.claim.chat.ClaimChatEvent.CloseFreeChatOverlay +import com.hedvig.feature.claim.chat.ClaimChatEvent.DismissConfirmEditDialog +import com.hedvig.feature.claim.chat.ClaimChatEvent.DismissErrorDialog +import com.hedvig.feature.claim.chat.ClaimChatEvent.FinishTaskAnimation +import com.hedvig.feature.claim.chat.ClaimChatEvent.HandledDeflectNavigation +import com.hedvig.feature.claim.chat.ClaimChatEvent.HandledOutcomeNavigation +import com.hedvig.feature.claim.chat.ClaimChatEvent.OpenFreeTextOverlay +import com.hedvig.feature.claim.chat.ClaimChatEvent.Regret +import com.hedvig.feature.claim.chat.ClaimChatEvent.RetryInitializing +import com.hedvig.feature.claim.chat.ClaimChatEvent.RetrySubmittingTaskStep +import com.hedvig.feature.claim.chat.ClaimChatEvent.Skip +import com.hedvig.feature.claim.chat.ClaimChatEvent.SubmitClaim +import com.hedvig.feature.claim.chat.ClaimChatEvent.UpdateFreeText import com.hedvig.feature.claim.chat.ClaimChatUiState import com.hedvig.feature.claim.chat.ClaimChatViewModel import com.hedvig.feature.claim.chat.ClaimChatViewModelFactory +import com.hedvig.feature.claim.chat.data.AudioRecordingStepState import com.hedvig.feature.claim.chat.data.ClaimChatErrorMessage import com.hedvig.feature.claim.chat.data.ClaimIntentOutcome import com.hedvig.feature.claim.chat.data.ClaimIntentStep @@ -114,6 +134,9 @@ import hedvig.resources.EMBARK_UPDATE_APP_BODY import hedvig.resources.EMBARK_UPDATE_APP_BUTTON import hedvig.resources.GENERAL_ARE_YOU_SURE import hedvig.resources.NETWORK_ERROR_ALERT_MESSAGE +import hedvig.resources.RESUME_CLAIM_LEAVE_BODY +import hedvig.resources.RESUME_CLAIM_LEAVE_CONFIRM +import hedvig.resources.RESUME_CLAIM_LEAVE_TITLE import hedvig.resources.Res import hedvig.resources.claims_alert_body import hedvig.resources.general_cancel_button @@ -136,10 +159,11 @@ internal fun ClaimChatDestination( isDevelopmentFlow: Boolean, navigateUp: () -> Unit, openPlayStore: () -> Unit, + resumeClaim: Boolean, ) { val claimChatViewModel = assistedMetroViewModel { - create(isDevelopmentFlow) + create(isDevelopmentFlow, resumeClaim) } Box(Modifier.fillMaxSize(), propagateMinConstraints = true) { BlurredGradientBackground() @@ -227,9 +251,12 @@ private fun ClaimChatScreen( openAppSettings: () -> Unit, openPlayStore: () -> Unit, ) { + val currentFreeText = (uiState.currentStep?.stepContent as? StepContent.AudioRecording) + ?.recordingState.let { it as? AudioRecordingStepState.FreeTextDescription } + ?.freeText FreeTextOverlay( freeTextMaxLength = uiState.showFreeTextOverlay?.maxLength ?: 2000, - freeTextValue = uiState.freeText, + freeTextValue = currentFreeText, freeTextHint = stringResource(Res.string.CLAIMS_TEXT_INPUT_POPOVER_PLACEHOLDER), freeTextTitle = stringResource(Res.string.CLAIMS_TEXT_INPUT_PLACEHOLDER), freeTextOnCancelClick = { @@ -273,9 +300,13 @@ private fun ClaimChatScreenContent( ) { var showCloseFlowDialog by rememberSaveable { mutableStateOf(false) } + // Flag on: only a resumable draft warrants a leave confirmation (it will be saved). + // Flag off: legacy behavior, always confirm. + val showLeaveConfirmation = if (uiState.resumeClaimEnabled) uiState.isResumable else true + NavigationEventHandler( state = rememberNavigationEventState(NavigationEventInfo.None), - isBackEnabled = uiState.steps.size > 1, + isBackEnabled = uiState.steps.size > 1 && showLeaveConfirmation, ) { showCloseFlowDialog = true } @@ -316,14 +347,22 @@ private fun ClaimChatScreenContent( ) } if (showCloseFlowDialog) { - HedvigAlertDialog( - title = stringResource(Res.string.GENERAL_ARE_YOU_SURE), - text = stringResource(Res.string.claims_alert_body), - onDismissRequest = { - showCloseFlowDialog = false - }, - onConfirmClick = navigateUp, - ) + if (uiState.resumeClaimEnabled) { + HedvigAlertDialog( + title = stringResource(Res.string.RESUME_CLAIM_LEAVE_TITLE), + text = stringResource(Res.string.RESUME_CLAIM_LEAVE_BODY), + confirmButtonLabel = stringResource(Res.string.RESUME_CLAIM_LEAVE_CONFIRM), + onDismissRequest = { showCloseFlowDialog = false }, + onConfirmClick = navigateUp, + ) + } else { + HedvigAlertDialog( + title = stringResource(Res.string.GENERAL_ARE_YOU_SURE), + text = stringResource(Res.string.claims_alert_body), + onDismissRequest = { showCloseFlowDialog = false }, + onConfirmClick = navigateUp, + ) + } } val lazyListState = rememberLazyListState() val coroutineScope = rememberCoroutineScope() @@ -350,12 +389,13 @@ private fun ClaimChatScreenContent( Box(modifier = modifier.fillMaxSize()) { Column(Modifier.matchParentSize()) { - val title = stringResource(Res.string.CHAT_CONVERSATION_CLAIM_TITLE) + val legacyTitle = stringResource(Res.string.CHAT_CONVERSATION_CLAIM_TITLE) + val title = if (uiState.resumeClaimEnabled) uiState.title ?: legacyTitle else legacyTitle TopAppBar( title = title, actionType = TopAppBarActionType.BACK, onActionClick = { - if (uiState.steps.size > 1) { + if (uiState.steps.size > 1 && showLeaveConfirmation) { showCloseFlowDialog = true } else { navigateUp() @@ -399,7 +439,7 @@ private fun ClaimChatScreenContent( openAppSettings = openAppSettings, modifier = Modifier.fillMaxSize(), closeFlow = { - if (uiState.steps.size > 1) { + if (uiState.steps.size > 1 && showLeaveConfirmation) { showCloseFlowDialog = true } else { navigateUp() @@ -485,7 +525,6 @@ private fun ClaimChatScrollableContent( StepContentSection( stepItem = item, - freeText = uiState.freeText, isCurrentStep = isCurrentStep, showAnimationSequence = showAnimationSequence, currentContinueButtonLoading = uiState.currentContinueButtonLoading, @@ -543,7 +582,6 @@ private fun ScrollToBottomButton(onClick: () -> Unit, modifier: Modifier = Modif @Composable private fun StepContentSection( stepItem: ClaimIntentStep, - freeText: String?, isCurrentStep: Boolean, showAnimationSequence: Boolean, currentContinueButtonLoading: Boolean, @@ -619,7 +657,6 @@ private fun StepContentSection( ) { StepBottomContent( stepItem = stepItem, - freeText = freeText, isCurrentStep = isCurrentStep, currentContinueButtonLoading = currentContinueButtonLoading, currentSkipButtonLoading = currentSkipButtonLoading, @@ -756,7 +793,6 @@ private fun CommonPaddingWrapper(content: @Composable () -> Unit) { @Composable private fun StepBottomContent( stepItem: ClaimIntentStep, - freeText: String?, isCurrentStep: Boolean, currentContinueButtonLoading: Boolean, currentSkipButtonLoading: Boolean, @@ -807,7 +843,6 @@ private fun StepBottomContent( clock = Clock.System, onShouldShowRequestPermissionRationale = shouldShowRequestPermissionRationale, openAppSettings = openAppSettings, - freeText = freeText, onEvent = onEvent, continueButtonLoading = currentContinueButtonLoading, skipButtonLoading = currentSkipButtonLoading, diff --git a/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/ui/ClaimChatUiComponents.kt b/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/ui/ClaimChatUiComponents.kt index 5f6836c811..446d357903 100644 --- a/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/ui/ClaimChatUiComponents.kt +++ b/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/ui/ClaimChatUiComponents.kt @@ -43,6 +43,7 @@ private fun PreviewClaimChatComponents() { recordingState = AudioRecordingStepState.FreeTextDescription( errorType = null, canSubmit = true, + freeText = "some not really long free text", ), clock = Clock.System, onShouldShowRequestPermissionRationale = { @@ -60,7 +61,6 @@ private fun PreviewClaimChatComponents() { onLaunchFullScreenEditText = {}, canSkip = true, onSkip = {}, - freeText = "some not really long free text", continueButtonLoading = false, skipButtonLoading = false, ) diff --git a/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/ui/step/FormStep.kt b/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/ui/step/FormStep.kt index 512c1689f7..b0da4e687f 100644 --- a/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/ui/step/FormStep.kt +++ b/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/ui/step/FormStep.kt @@ -190,7 +190,8 @@ private fun FormContent( FieldType.TEXT -> { TextInputBubble( questionLabel = field.title, - text = field.selectedOptions.getOrNull(0)?.text, + text = field.selectedOptions.getOrNull(0)?.text + ?: field.defaultValues.getOrNull(0)?.text, suffix = field.suffix, onInput = { answer -> onSelectFieldAnswer( diff --git a/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/ui/step/audiorecording/AudioRecorder.kt b/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/ui/step/audiorecording/AudioRecorder.kt index 179e16d2d4..8209440bb3 100644 --- a/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/ui/step/audiorecording/AudioRecorder.kt +++ b/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/ui/step/audiorecording/AudioRecorder.kt @@ -63,6 +63,8 @@ import com.hedvig.android.design.system.hedvig.LocalContentColor import com.hedvig.android.design.system.hedvig.Surface import com.hedvig.android.design.system.hedvig.tokens.MotionTokens import com.hedvig.audio.player.data.PlayableAudioSource +import com.hedvig.audio.player.data.SignedAudioUrl +import com.hedvig.feature.claim.chat.data.AudioPath import com.hedvig.feature.claim.chat.data.AudioRecordingStepState import com.hedvig.feature.claim.chat.ui.common.SkippedLabel import hedvig.resources.A11Y_AUDIO_RECORDING @@ -252,7 +254,15 @@ private fun Playback( if (!uiState.isPrepared) { HedvigCircularProgressIndicator() } else { - val audioPlayer = rememberAudioPlayer(PlayableAudioSource.LocalFilePath(uiState.filePath)) + val audioPlayer = when (uiState.audioPath) { + is AudioPath.FilePath -> rememberAudioPlayer(PlayableAudioSource.LocalFilePath(uiState.audioPath.filePath)) + + is AudioPath.RemoteUrl -> rememberAudioPlayer( + PlayableAudioSource.RemoteUrl( + SignedAudioUrl.fromSignedAudioUrlString(uiState.audioPath.remoteUrl), + ), + ) + } if (!isCurrentStep) { HedvigAudioPlayer( audioPlayer = audioPlayer, diff --git a/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/ui/step/audiorecording/AudioRecordingStepSections.kt b/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/ui/step/audiorecording/AudioRecordingStepSections.kt index 4a063a4a89..34f584d9ea 100644 --- a/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/ui/step/audiorecording/AudioRecordingStepSections.kt +++ b/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/ui/step/audiorecording/AudioRecordingStepSections.kt @@ -98,8 +98,10 @@ import com.hedvig.audio.player.data.AudioPlayer import com.hedvig.audio.player.data.AudioPlayerState import com.hedvig.audio.player.data.PlayableAudioSource import com.hedvig.audio.player.data.ProgressPercentage +import com.hedvig.audio.player.data.SignedAudioUrl import com.hedvig.feature.claim.chat.ClaimChatEvent import com.hedvig.feature.claim.chat.FreeTextRestrictions +import com.hedvig.feature.claim.chat.data.AudioPath import com.hedvig.feature.claim.chat.data.AudioRecordingStepState import com.hedvig.feature.claim.chat.data.ClaimIntentStep import com.hedvig.feature.claim.chat.data.FreeTextErrorType @@ -140,7 +142,6 @@ import org.jetbrains.compose.resources.stringResource @Composable internal fun AudioRecordingStep( item: ClaimIntentStep, - freeText: String?, stepContent: StepContent.AudioRecording, onShowFreeText: () -> Unit, onSwitchToAudioRecording: () -> Unit, @@ -185,7 +186,6 @@ internal fun AudioRecordingStep( canSkip = stepContent.isSkippable, onSkip = onSkip, isCurrentStep = isCurrentStep, - freeText = freeText, continueButtonLoading = continueButtonLoading, skipButtonLoading = skipButtonLoading, ) @@ -201,7 +201,6 @@ internal fun AudioRecordingStep( @Composable internal fun AudioRecorderBubble( recordingState: AudioRecordingStepState, - freeText: String?, clock: Clock, onShouldShowRequestPermissionRationale: (String) -> Boolean, startRecording: () -> Unit, @@ -238,7 +237,7 @@ internal fun AudioRecorderBubble( submitFreeText = submitFreeText, showAudioRecording = onSwitchToAudioRecording, onLaunchFullScreenEditText = onLaunchFullScreenEditText, - freeText = freeText, + freeText = recordingState.freeText, hasError = recordingState.hasError, errorType = recordingState.errorType, isCurrentStep = isCurrentStep, @@ -284,9 +283,17 @@ internal fun AudioRecorderBubble( } } else { if (recordingState is AudioRecordingStepState.AudioRecording.Playback) { - val audioPlayer = rememberAudioPlayer( - PlayableAudioSource.LocalFilePath(recordingState.filePath), - ) + val audioPlayer = when (recordingState.audioPath) { + is AudioPath.FilePath -> rememberAudioPlayer( + PlayableAudioSource.LocalFilePath(recordingState.audioPath.filePath), + ) + + is AudioPath.RemoteUrl -> rememberAudioPlayer( + PlayableAudioSource.RemoteUrl( + SignedAudioUrl.fromSignedAudioUrlString(recordingState.audioPath.remoteUrl), + ), + ) + } HedvigAudioPlayer( audioPlayer = audioPlayer, Modifier.padding(start = sentAnswersStartPadding), @@ -355,9 +362,13 @@ private fun AudioRecordingBottomSheet( } val audioPlayer = (audioRecordingState as? AudioRecordingStepState.AudioRecording.Playback)?.let { - rememberAudioPlayer( - PlayableAudioSource.LocalFilePath(it.filePath), - ) + if (it.audioPath is AudioPath.FilePath) { + rememberAudioPlayer( + PlayableAudioSource.LocalFilePath(it.audioPath.filePath), + ) + } else { + null + } } LaunchedEffect(bottomSheetState.isVisible) { @@ -1156,19 +1167,19 @@ private class AudioRecordingSheetContentStateProvider : filePath = "/path/to/recording.mp4", ), AudioRecordingStepState.AudioRecording.Playback( - filePath = "/path/to/recording.mp4", + audioPath = AudioPath.FilePath("/path/to/recording.mp4"), isPlaying = false, isPrepared = true, hasError = false, ), AudioRecordingStepState.AudioRecording.Playback( - filePath = "/path/to/recording.mp4", + audioPath = AudioPath.FilePath("/path/to/recording.mp4"), isPlaying = false, isPrepared = false, hasError = false, ), AudioRecordingStepState.AudioRecording.Playback( - filePath = "/path/to/recording.mp4", + audioPath = AudioPath.FilePath("/path/to/recording.mp4"), isPlaying = false, isPrepared = false, hasError = true, diff --git a/app/feature/feature-home/build.gradle.kts b/app/feature/feature-home/build.gradle.kts index e739b6f13a..c86627faa7 100644 --- a/app/feature/feature-home/build.gradle.kts +++ b/app/feature/feature-home/build.gradle.kts @@ -39,6 +39,7 @@ dependencies { implementation(projects.coreResources) implementation(projects.crossSells) implementation(projects.dataAddons) + implementation(projects.dataClaimIntent) implementation(projects.dataContract) implementation(projects.dataConversations) implementation(projects.dataCrossSellAfterClaimClosed) diff --git a/app/feature/feature-home/src/main/graphql/QueryHome.graphql b/app/feature/feature-home/src/main/graphql/QueryHome.graphql index 330ac27cf9..9a91318f59 100644 --- a/app/feature/feature-home/src/main/graphql/QueryHome.graphql +++ b/app/feature/feature-home/src/main/graphql/QueryHome.graphql @@ -1,5 +1,10 @@ -query Home($claimsHistoryFlag: Boolean!) { +query Home($claimsHistoryFlag: Boolean!, $resumeClaimEnabled: Boolean!) { currentMember { + resumableClaimIntent @include(if: $resumeClaimEnabled) { + id + displayName + createdAt + } claims@skip(if: $claimsHistoryFlag) { ...ClaimFragment } 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 f2def2bd9a..aa988c81e7 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 @@ -32,12 +32,16 @@ import com.hedvig.android.ui.claimstatus.model.ClaimStatusCardUiState import com.hedvig.android.ui.emergency.FirstVetSection import dev.zacsweers.metro.Inject import kotlin.time.Clock +import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.isActive import kotlinx.datetime.LocalDate @@ -61,137 +65,152 @@ internal class GetHomeDataUseCaseImpl( private val getAddonBannerInfoUseCase: GetAddonBannerInfoUseCase, private val hasAnyActiveConversationUseCase: HasAnyActiveConversationUseCase, ) : GetHomeDataUseCase { + @OptIn(ExperimentalCoroutinesApi::class) override fun invoke(forceNetworkFetch: Boolean): Flow> { - return combine( - apolloClient.query(HomeQuery(true)) - .fetchPolicy(if (forceNetworkFetch) FetchPolicy.NetworkOnly else FetchPolicy.CacheAndNetwork) - .safeFlow(), - flow { - while (currentCoroutineContext().isActive) { - emitAll( - apolloClient.query(UnreadMessageCountQuery()) - .fetchPolicy(FetchPolicy.CacheAndNetwork) - .safeFlow(), - ) - delay(5.seconds) - } - }, - getMemberRemindersUseCase.invoke(), - flow { - emitAll(getAddonBannerInfoUseCase.invoke(AddonBannerSource.INSURANCES_TAB)) - }, - featureManager.isFeatureEnabled(Feature.ENABLE_NEW_CONVERSATION_FROM_INBOX), - hasAnyActiveConversationUseCase.invoke(alwaysHitTheNetwork = true), - ) { - homeQueryDataResult, - unreadMessageCountResult, - memberReminders, - travelBannerInfo, - inboxAlwaysAvailable, - anyActiveConversations, - -> - either { - val homeQueryData: HomeQuery.Data = homeQueryDataResult.bind() - val contractStatus = homeQueryData.currentMember.toContractStatus() - val veryImportantMessages = homeQueryData.currentMember.importantMessages.map { - HomeData.VeryImportantMessage( - id = it.id, - message = it.message, - linkInfo = it.linkInfo?.let { linkInfo -> - if (linkInfo.url.isEmpty()) { - logcat(LogPriority.ERROR) { "Backend should never return a present linkInfo with an empty url string" } - null + return featureManager.isFeatureEnabled(Feature.ENABLE_CLAIM_INTENT_RESUME) + .flatMapLatest { resumeClaimEnabled -> + combine( + apolloClient.query(HomeQuery(true, resumeClaimEnabled)) + .fetchPolicy(if (forceNetworkFetch) FetchPolicy.NetworkOnly else FetchPolicy.CacheAndNetwork) + .safeFlow(), + flow { + while (currentCoroutineContext().isActive) { + emitAll( + apolloClient.query(UnreadMessageCountQuery()) + .fetchPolicy(FetchPolicy.CacheAndNetwork) + .safeFlow(), + ) + delay(5.seconds) + } + }, + getMemberRemindersUseCase.invoke(), + flow { + emitAll(getAddonBannerInfoUseCase.invoke(AddonBannerSource.INSURANCES_TAB)) + }, + featureManager.isFeatureEnabled(Feature.ENABLE_NEW_CONVERSATION_FROM_INBOX), + hasAnyActiveConversationUseCase.invoke(alwaysHitTheNetwork = true), + ) { + homeQueryDataResult, + unreadMessageCountResult, + memberReminders, + travelBannerInfo, + inboxAlwaysAvailable, + anyActiveConversations, + -> + either { + val homeQueryData: HomeQuery.Data = homeQueryDataResult.bind() + val contractStatus = homeQueryData.currentMember.toContractStatus() + val veryImportantMessages = homeQueryData.currentMember.importantMessages.map { + HomeData.VeryImportantMessage( + id = it.id, + message = it.message, + linkInfo = it.linkInfo?.let { linkInfo -> + if (linkInfo.url.isEmpty()) { + logcat(LogPriority.ERROR) { + "Backend should never return a present linkInfo with an empty url string" + } + null + } else { + val buttonText = linkInfo.buttonText.takeIf { it.isNotEmpty() } + if (buttonText == null) { + logcat(LogPriority.ERROR) { + "Backend should never return a present buttonText with an empty string" + } + } + HomeData.VeryImportantMessage.LinkInfo( + buttonText = buttonText, + link = linkInfo.url, + ) + } + }, + ) + } + val crossSellsData = homeQueryData.currentMember.crossSellV2 + + val recommendedCrossSell = crossSellsData.recommendedCrossSell?.let { + val bundleProgress = if (it.numberOfEligibleContracts > 0 && it.discountPercent != null) { + BundleProgress(it.numberOfEligibleContracts, it.discountPercent) } else { - val buttonText = linkInfo.buttonText.takeIf { it.isNotEmpty() } - if (buttonText == null) { - logcat(LogPriority.ERROR) { "Backend should never return a present buttonText with an empty string" } - } - HomeData.VeryImportantMessage.LinkInfo( - buttonText = buttonText, - link = linkInfo.url, - ) + null } - }, - ) - } - val crossSellsData = homeQueryData.currentMember.crossSellV2 - - val recommendedCrossSell = crossSellsData.recommendedCrossSell?.let { - val bundleProgress = if (it.numberOfEligibleContracts > 0 && it.discountPercent != null) { - BundleProgress(it.numberOfEligibleContracts, it.discountPercent) - } else { - null + RecommendedCrossSell( + crossSell = it.crossSell.toCrossSell(), + bannerText = it.bannerText, + buttonText = it.buttonText, + discountText = it.discountText, + buttonDescription = it.buttonDescription, + bundleProgress = bundleProgress, + backgroundPillowImages = it.backgroundPillowImages?.let { images -> + images.leftImage.src to images.rightImage.src + }, + ) + } + val otherCrossSellsData = crossSellsData.otherCrossSells.map { + it.toCrossSell() + } + val recommendedAddon = crossSellsData.recommendedAddon?.let { + RecommendedAddon( + id = it.id, + title = it.title, + buttonTitle = it.buttonTitle, + description = it.description, + deepLink = it.deepLink, + banner = it.banner, + benefits = it.benefits, + pillowImageSmall = it.pillowImageSmall.src, + pillowImageLarge = it.pillowImageLarge.src, + ) + } + val crossSells = CrossSellSheetData( + recommendedCrossSell = recommendedCrossSell, + otherCrossSells = otherCrossSellsData, + recommendedAddon = recommendedAddon, + ) + val showChatIcon = shouldShowChatButton( + isInboxEnabledFromKillSwitch = inboxAlwaysAvailable, + hasActiveConversations = anyActiveConversations.bind(), + ) + val unreadMessageCountData = unreadMessageCountResult.bind() + val hasUnseenChatMessages = unreadMessageCountData + .currentMember + .conversations + .map { it.unreadMessageCount } + .plus(unreadMessageCountData.currentMember.legacyConversation?.unreadMessageCount) + .any { it != null && it > 0 } + val firstVetActions = homeQueryData.currentMember.memberActions + ?.firstVetAction?.sections?.map { section -> + FirstVetSection( + section.buttonTitle, + section.description, + section.title, + section.url, + ) + } ?: emptyList() + val travelBannerInfo = travelBannerInfo.getOrNull() + HomeData( + contractStatus = contractStatus, + claimStatusCardsData = homeQueryData.claimStatusCards(), + veryImportantMessages = veryImportantMessages, + memberReminders = memberReminders, + hasUnseenChatMessages = hasUnseenChatMessages, + showHelpCenter = true, + firstVetSections = firstVetActions, + crossSells = crossSells, + travelBannerInfo = travelBannerInfo?.firstOrNull(), + showChatIcon = showChatIcon, + draftClaim = homeQueryData.currentMember.resumableClaimIntent?.let { resumableClaimIntent -> + HomeData.DraftClaim( + id = resumableClaimIntent.id, + displayName = resumableClaimIntent.displayName, + startedAt = resumableClaimIntent.createdAt, + ) + }, + ) + }.onLeft { error: ApolloOperationError -> + logcat(operationError = error) { "GetHomeDataUseCase failed with $error" } } - RecommendedCrossSell( - crossSell = it.crossSell.toCrossSell(), - bannerText = it.bannerText, - buttonText = it.buttonText, - discountText = it.discountText, - buttonDescription = it.buttonDescription, - bundleProgress = bundleProgress, - backgroundPillowImages = it.backgroundPillowImages?.let { images -> - images.leftImage.src to images.rightImage.src - }, - ) - } - val otherCrossSellsData = crossSellsData.otherCrossSells.map { - it.toCrossSell() } - val recommendedAddon = crossSellsData.recommendedAddon?.let { - RecommendedAddon( - id = it.id, - title = it.title, - buttonTitle = it.buttonTitle, - description = it.description, - deepLink = it.deepLink, - banner = it.banner, - benefits = it.benefits, - pillowImageSmall = it.pillowImageSmall.src, - pillowImageLarge = it.pillowImageLarge.src, - ) - } - val crossSells = CrossSellSheetData( - recommendedCrossSell = recommendedCrossSell, - otherCrossSells = otherCrossSellsData, - recommendedAddon = recommendedAddon, - ) - val showChatIcon = shouldShowChatButton( - isInboxEnabledFromKillSwitch = inboxAlwaysAvailable, - hasActiveConversations = anyActiveConversations.bind(), - ) - val unreadMessageCountData = unreadMessageCountResult.bind() - val hasUnseenChatMessages = unreadMessageCountData - .currentMember - .conversations - .map { it.unreadMessageCount } - .plus(unreadMessageCountData.currentMember.legacyConversation?.unreadMessageCount) - .any { it != null && it > 0 } - val firstVetActions = homeQueryData.currentMember.memberActions - ?.firstVetAction?.sections?.map { section -> - FirstVetSection( - section.buttonTitle, - section.description, - section.title, - section.url, - ) - } ?: emptyList() - val travelBannerInfo = travelBannerInfo.getOrNull() - HomeData( - contractStatus = contractStatus, - claimStatusCardsData = homeQueryData.claimStatusCards(), - veryImportantMessages = veryImportantMessages, - memberReminders = memberReminders, - hasUnseenChatMessages = hasUnseenChatMessages, - showHelpCenter = true, - firstVetSections = firstVetActions, - crossSells = crossSells, - travelBannerInfo = travelBannerInfo?.firstOrNull(), - showChatIcon = showChatIcon, - ) - }.onLeft { error: ApolloOperationError -> - logcat(operationError = error) { "GetHomeDataUseCase failed with $error" } } - } } private fun shouldShowChatButton(isInboxEnabledFromKillSwitch: Boolean, hasActiveConversations: Boolean): Boolean { @@ -295,6 +314,7 @@ data class HomeData( val firstVetSections: List, val crossSells: CrossSellSheetData, val travelBannerInfo: AddonBannerInfo?, + val draftClaim: DraftClaim?, ) { @Immutable data class ClaimStatusCardsData( @@ -312,6 +332,18 @@ data class HomeData( ) } + data class DraftClaim( + val id: String, + val displayName: String?, + val startedAt: Instant, + ) { + /** + * Drafts are kept for 7 days on the backend ("Your claim is automatically saved for 7 days"). + * Client-side heuristic, same as iOS. + */ + fun isExpired(now: Instant): Boolean = now > startedAt + 7.days + } + sealed interface ContractStatus { data object Active : ContractStatus 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 9b9c06da56..790711a60f 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 @@ -57,6 +57,7 @@ internal class GetHomeDataUseCaseDemo : GetHomeDataUseCase { ), travelBannerInfo = null, showChatIcon = false, + draftClaim = null, ).right(), ) } diff --git a/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/navigation/HomeEntries.kt b/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/navigation/HomeEntries.kt index fbac21457e..d45e66cc44 100644 --- a/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/navigation/HomeEntries.kt +++ b/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/navigation/HomeEntries.kt @@ -25,7 +25,7 @@ fun EntryProviderScope.homeEntries( navigateToContactInfo: () -> Unit, navigateToMissingInfo: (String, CoInsuredFlowType) -> Unit, navigateToHelpCenter: () -> Unit, - navigateToClaimChat: () -> Unit, + navigateToClaimChat: (resumeClaim: Boolean) -> Unit, navigateToChipIdScreen: () -> Unit, openAppSettings: () -> Unit, openUrl: (String) -> Unit, @@ -38,7 +38,7 @@ fun EntryProviderScope.homeEntries( viewModel = viewModel, onNavigateToInbox = dropUnlessResumed { onNavigateToInbox() }, onNavigateToNewConversation = dropUnlessResumed { onNavigateToNewConversation() }, - navigateToClaimChat = dropUnlessResumed { navigateToClaimChat() }, + navigateToClaimChat = dropUnlessResumed { resumeClaim -> navigateToClaimChat(resumeClaim) }, onClaimDetailCardClicked = dropUnlessResumed { claimId: String -> navigateToClaimDetails(claimId) }, 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 f573c89a06..259c015860 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 @@ -58,7 +58,9 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import arrow.core.NonEmptyList import arrow.core.nonEmptyListOf +import arrow.core.toNonEmptyListOrNull import coil3.ImageLoader import com.google.accompanist.permissions.isGranted import com.hedvig.android.compose.pager.indicator.HorizontalPagerIndicator @@ -74,6 +76,9 @@ import com.hedvig.android.data.coinsured.CoInsuredFlowType import com.hedvig.android.data.contract.CrossSell import com.hedvig.android.data.contract.ImageAsset import com.hedvig.android.design.system.hedvig.ButtonDefaults.ButtonStyle.Secondary +import com.hedvig.android.design.system.hedvig.DraftClaimDialog +import com.hedvig.android.design.system.hedvig.ErrorDialog +import com.hedvig.android.design.system.hedvig.HedvigAlertDialog import com.hedvig.android.design.system.hedvig.HedvigButton import com.hedvig.android.design.system.hedvig.HedvigErrorSection import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgressDebounced @@ -119,6 +124,7 @@ import com.hedvig.android.pullrefresh.PullRefreshState import com.hedvig.android.pullrefresh.pullRefresh import com.hedvig.android.pullrefresh.rememberPullRefreshState import com.hedvig.android.ui.claimstatus.ClaimStatusCards +import com.hedvig.android.ui.claimstatus.model.ClaimCardUiState import com.hedvig.android.ui.claimstatus.model.ClaimPillType.Claim import com.hedvig.android.ui.claimstatus.model.ClaimPillType.Closed.NotCompensated import com.hedvig.android.ui.claimstatus.model.ClaimProgressSegment @@ -127,8 +133,14 @@ import com.hedvig.android.ui.claimstatus.model.ClaimProgressSegment.SegmentType. import com.hedvig.android.ui.claimstatus.model.ClaimStatusCardUiState import com.hedvig.android.ui.emergency.FirstVetSection import hedvig.resources.CHAT_NEW_MESSAGE +import hedvig.resources.RESUME_CLAIM_DELETE_BODY +import hedvig.resources.RESUME_CLAIM_DELETE_BUTTON +import hedvig.resources.RESUME_CLAIM_DELETE_TITLE +import hedvig.resources.RESUME_CLAIM_EXPIRED_BODY +import hedvig.resources.RESUME_CLAIM_EXPIRED_TITLE import hedvig.resources.Res import hedvig.resources.TOAST_NEW_OFFER +import hedvig.resources.general_cancel_button import hedvig.resources.home_tab_active_in_future_info import hedvig.resources.home_tab_claim_button_text import hedvig.resources.home_tab_get_help @@ -155,7 +167,7 @@ internal fun HomeDestination( viewModel: HomeViewModel, onNavigateToInbox: () -> Unit, onNavigateToNewConversation: () -> Unit, - navigateToClaimChat: () -> Unit, + navigateToClaimChat: (resumeClaim: Boolean) -> Unit, onClaimDetailCardClicked: (claimId: String) -> Unit, navigateToConnectPayment: () -> Unit, navigateToConnectPayout: () -> Unit, @@ -187,6 +199,7 @@ internal fun HomeDestination( openAppSettings = openAppSettings, navigateToMissingInfo = navigateToMissingInfo, markMessageAsSeen = { viewModel.emit(HomeEvent.MarkMessageAsSeen(it)) }, + deleteDraftClaim = { draftId -> viewModel.emit(HomeEvent.DeleteDraftClaim(draftId)) }, navigateToFirstVet = navigateToFirstVet, markCrossSellsNotificationAsSeen = { viewModel.emit(HomeEvent.MarkCardCrossSellsAsSeen) }, navigateToContactInfo = navigateToContactInfo, @@ -205,7 +218,7 @@ private fun HomeScreen( reload: () -> Unit, onNavigateToInbox: () -> Unit, onNavigateToNewConversation: () -> Unit, - navigateToClaimChat: () -> Unit, + navigateToClaimChat: (resumeClaim: Boolean) -> Unit, onClaimDetailCardClicked: (claimId: String) -> Unit, navigateToConnectPayment: () -> Unit, navigateToConnectPayout: () -> Unit, @@ -213,6 +226,7 @@ private fun HomeScreen( openUrl: (String) -> Unit, openCrossSellUrl: (String) -> Unit, markMessageAsSeen: (String) -> Unit, + deleteDraftClaim: (String) -> Unit, openAppSettings: () -> Unit, navigateToMissingInfo: (String, CoInsuredFlowType) -> Unit, navigateToFirstVet: (List) -> Unit, @@ -237,11 +251,57 @@ private fun HomeScreen( onCrossSellClick = openCrossSellUrl, imageLoader = imageLoader, ) + val startClaimBottomSheetState = rememberHedvigBottomSheetState() StartClaimBottomSheet( state = startClaimBottomSheetState, - navigateToClaimChat = navigateToClaimChat, + navigateToClaimChat = { + navigateToClaimChat(false) + }, ) + val draftClaim = (uiState as? Success)?.draftClaim + var showDraftClaimDialog by remember { mutableStateOf(false) } + var showDraftExpiredDialog by remember { mutableStateOf(false) } + var draftIdPendingDeleteConfirmation by remember { mutableStateOf(null) } + if (showDraftClaimDialog) { + DraftClaimDialog( + onDismissRequest = { showDraftClaimDialog = false }, + onContinueDraft = { + showDraftClaimDialog = false + navigateToClaimChat(true) + }, + onStartNewClaim = { + showDraftClaimDialog = false + startClaimBottomSheetState.show(Unit) + }, + ) + } + if (showDraftExpiredDialog) { + // The draft is expired, so acknowledging the notice (Close button, scrim, or back) removes it. + // Matches the Ready-for-dev design: single Close, closing removes the draft claim card. + ErrorDialog( + title = stringResource(Res.string.RESUME_CLAIM_EXPIRED_TITLE), + message = stringResource(Res.string.RESUME_CLAIM_EXPIRED_BODY), + onDismiss = { + showDraftExpiredDialog = false + draftClaim?.let { deleteDraftClaim(it.id) } + }, + ) + } + val draftIdToDelete = draftIdPendingDeleteConfirmation + if (draftIdToDelete != null) { + HedvigAlertDialog( + title = stringResource(Res.string.RESUME_CLAIM_DELETE_TITLE), + text = stringResource(Res.string.RESUME_CLAIM_DELETE_BODY), + confirmButtonLabel = stringResource(Res.string.RESUME_CLAIM_DELETE_BUTTON), + dismissButtonLabel = stringResource(Res.string.general_cancel_button), + onDismissRequest = { draftIdPendingDeleteConfirmation = null }, + onConfirmClick = { + draftIdPendingDeleteConfirmation = null + deleteDraftClaim(draftIdToDelete) + }, + ) + } Box(Modifier.fillMaxSize()) { val toolbarHeight = 64.dp val transition = updateTransition(targetState = uiState, label = "home ui state") @@ -277,7 +337,23 @@ private fun HomeScreen( navigateToConnectPayment = navigateToConnectPayment, navigateToConnectPayout = navigateToConnectPayout, navigateToHelpCenter = navigateToHelpCenter, - openClaimFlowSheet = startClaimBottomSheetState::show, + openClaimFlowSheet = { + if (draftClaim != null) { + showDraftClaimDialog = true + } else { + startClaimBottomSheetState.show(Unit) + } + }, + onContinueDraftClaim = { + if (draftClaim != null) { + if (draftClaim.isExpired(Clock.System.now())) { + showDraftExpiredDialog = true + } else { + navigateToClaimChat(true) + } + } + }, + onDeleteDraftClaim = { draftId -> draftIdPendingDeleteConfirmation = draftId }, openAppSettings = openAppSettings, openUrl = openUrl, navigateToMissingInfo = navigateToMissingInfo, @@ -426,6 +502,8 @@ private fun HomeScreenSuccess( navigateToConnectPayout: () -> Unit, navigateToHelpCenter: () -> Unit, openClaimFlowSheet: () -> Unit, + onContinueDraftClaim: () -> Unit, + onDeleteDraftClaim: (String) -> Unit, openAppSettings: () -> Unit, openUrl: (String) -> Unit, markMessageAsSeen: (String) -> Unit, @@ -473,10 +551,18 @@ private fun HomeScreenSuccess( ) }, claimStatusCards = { - if (uiState.claimStatusCardsData != null) { + val claimCards: NonEmptyList? = buildList { + uiState.draftClaim?.let { draftClaim -> + add(ClaimCardUiState.Draft(draftClaim.id, draftClaim.displayName, draftClaim.startedAt)) + } + uiState.claimStatusCardsData?.claimStatusCardsUiState?.forEach { add(ClaimCardUiState.Claim(it)) } + }.toNonEmptyListOrNull() + if (claimCards != null) { ClaimStatusCards( onClick = onClaimDetailCardClicked, - claimStatusCardsUiState = uiState.claimStatusCardsData.claimStatusCardsUiState, + onContinueDraftClaim = onContinueDraftClaim, + onDeleteDraftClaim = onDeleteDraftClaim, + claimCardsUiState = claimCards, contentPadding = PaddingValues(horizontal = 16.dp) + horizontalInsets, ) } @@ -797,6 +883,7 @@ private fun PreviewHomeScreen( flowType = FlowType.APP_TRAVEL_PLUS_SELL_OR_UPGRADE, ), isProduction = true, + draftClaim = null, ), notificationPermissionState = rememberPreviewNotificationPermissionState(), reload = {}, @@ -812,6 +899,7 @@ private fun PreviewHomeScreen( openAppSettings = {}, navigateToMissingInfo = { _, _ -> }, markMessageAsSeen = {}, + deleteDraftClaim = {}, navigateToFirstVet = {}, markCrossSellsNotificationAsSeen = {}, navigateToContactInfo = {}, @@ -844,6 +932,7 @@ private fun PreviewHomeScreenWithError() { openAppSettings = {}, navigateToMissingInfo = { _, _ -> }, markMessageAsSeen = {}, + deleteDraftClaim = {}, navigateToFirstVet = {}, markCrossSellsNotificationAsSeen = {}, navigateToContactInfo = {}, @@ -882,6 +971,7 @@ private fun PreviewHomeScreenAllHomeTextTypes( chatAction = ChatAction, addonBannerInfo = null, isProduction = true, + draftClaim = null, ), notificationPermissionState = rememberPreviewNotificationPermissionState(), reload = {}, @@ -897,6 +987,7 @@ private fun PreviewHomeScreenAllHomeTextTypes( openAppSettings = {}, navigateToMissingInfo = { _, _ -> }, markMessageAsSeen = {}, + deleteDraftClaim = {}, navigateToFirstVet = {}, markCrossSellsNotificationAsSeen = {}, navigateToContactInfo = {}, 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 3a9bc6135a..1d17842b44 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,9 +14,12 @@ import com.hedvig.android.apollo.ApolloOperationError import com.hedvig.android.core.common.ApplicationScope import com.hedvig.android.crosssells.CrossSellSheetData import com.hedvig.android.data.addons.data.AddonBannerInfo +import com.hedvig.android.data.claimintent.DeleteClaimIntentDraftUseCase 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.SeenImportantMessagesStorage +import com.hedvig.android.logger.LogPriority +import com.hedvig.android.logger.logcat import com.hedvig.android.memberreminders.MemberReminders import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope @@ -36,6 +39,7 @@ internal class HomePresenter( private val crossSellHomeNotificationService: CrossSellHomeNotificationService, private val applicationScope: ApplicationScope, private val isProduction: Boolean, + private val deleteClaimIntentDraftUseCase: DeleteClaimIntentDraftUseCase, ) : MoleculePresenter { @Composable override fun MoleculePresenterScope.present(lastState: HomeUiState): HomeUiState { @@ -66,6 +70,15 @@ internal class HomePresenter( is HomeEvent.CrossSellToolTipShown -> { crossSellToolTipShownEpochDay = homeEvent.epochDay } + + is HomeEvent.DeleteDraftClaim -> { + launch { + deleteClaimIntentDraftUseCase.invoke(homeEvent.draftId).fold( + ifLeft = { logcat(LogPriority.ERROR) { "Failed to delete draft claim: $it" } }, + ifRight = { loadIteration++ }, + ) + } + } } } LaunchedEffect(crossSellToolTipShownEpochDay) { @@ -138,6 +151,7 @@ internal class HomePresenter( crossSellsAction = successData.crossSellsAction, addonBannerInfo = successData.addonBannerInfo, isProduction = isProduction, + draftClaim = successData.draftClaim, ) } } @@ -152,6 +166,8 @@ internal sealed interface HomeEvent { data object MarkCardCrossSellsAsSeen : HomeEvent data class CrossSellToolTipShown(val epochDay: Long) : HomeEvent + + data class DeleteDraftClaim(val draftId: String) : HomeEvent } internal sealed interface HomeUiState { @@ -177,6 +193,7 @@ internal sealed interface HomeUiState { val isProduction: Boolean, override val isHelpCenterEnabled: Boolean, override val hasUnseenChatMessages: Boolean, + val draftClaim: HomeData.DraftClaim?, ) : HomeUiState data class Error(val message: String?) : HomeUiState @@ -195,6 +212,7 @@ private data class SuccessData( val crossSellsAction: HomeTopBarAction.CrossSellsAction?, val hasUnseenChatMessages: Boolean, val addonBannerInfo: AddonBannerInfo?, + val draftClaim: HomeData.DraftClaim?, ) { companion object { fun fromLastState(lastState: HomeUiState): SuccessData? { @@ -210,6 +228,7 @@ private data class SuccessData( hasUnseenChatMessages = lastState.hasUnseenChatMessages, addonBannerInfo = lastState.addonBannerInfo, chatAction = lastState.chatAction, + draftClaim = lastState.draftClaim, ) } @@ -258,6 +277,7 @@ private data class SuccessData( hasUnseenChatMessages = homeData.hasUnseenChatMessages, addonBannerInfo = homeData.travelBannerInfo, chatAction = if (homeData.showChatIcon) HomeTopBarAction.ChatAction else null, + draftClaim = homeData.draftClaim, ) } } diff --git a/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomeViewModel.kt b/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomeViewModel.kt index 49654c4e97..92584d1d60 100644 --- a/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomeViewModel.kt +++ b/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomeViewModel.kt @@ -4,6 +4,7 @@ import com.hedvig.android.core.buildconstants.HedvigBuildConstants import com.hedvig.android.core.common.ApplicationScope import com.hedvig.android.core.common.di.ActivityRetainedScope import com.hedvig.android.core.common.di.HedvigViewModel +import com.hedvig.android.data.claimintent.DeleteClaimIntentDraftUseCase import com.hedvig.android.feature.home.home.data.GetHomeDataUseCase import com.hedvig.android.feature.home.home.data.SeenImportantMessagesStorage import com.hedvig.android.molecule.public.MoleculeViewModel @@ -18,6 +19,7 @@ internal class HomeViewModel( crossSellHomeNotificationService: CrossSellHomeNotificationService, applicationScope: ApplicationScope, hedvigBuildConstants: HedvigBuildConstants, + deleteClaimIntentDraftUseCase: DeleteClaimIntentDraftUseCase, ) : MoleculeViewModel( HomeUiState.Loading, HomePresenter( @@ -26,5 +28,6 @@ internal class HomeViewModel( crossSellHomeNotificationService, applicationScope, hedvigBuildConstants.isProduction, + deleteClaimIntentDraftUseCase, ), ) 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 4e01c7ed18..5b9dd12977 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 @@ -96,7 +96,7 @@ internal class GetHomeUseCaseTest { val getHomeDataUseCase = GetHomeDataUseCaseImpl( apolloClient.apply { registerTestResponse( - HomeQuery(true), + HomeQuery(true, true), HomeQuery.Data(OctopusFakeResolver), ) apolloClient.registerTestResponse( @@ -145,7 +145,7 @@ internal class GetHomeUseCaseTest { val getHomeDataUseCase = GetHomeDataUseCaseImpl( apolloClient.apply { registerTestResponse( - HomeQuery(true), + HomeQuery(true, true), HomeQuery.Data(OctopusFakeResolver), ) apolloClient.registerTestResponse( @@ -180,7 +180,7 @@ internal class GetHomeUseCaseTest { val getHomeDataUseCase = testUseCaseWithoutReminders() apolloClient.registerTestResponse( - HomeQuery(true), + HomeQuery(true, true), HomeQuery.Data(OctopusFakeResolver) { currentMember = buildMember { importantMessages = List(3) { index -> @@ -222,7 +222,7 @@ internal class GetHomeUseCaseTest { val getHomeDataUseCase = testUseCaseWithoutReminders() apolloClient.registerTestResponse( - HomeQuery(true), + HomeQuery(true, true), HomeQuery.Data(OctopusFakeResolver) { currentMember = buildMember { importantMessages = emptyList() @@ -251,7 +251,7 @@ internal class GetHomeUseCaseTest { val getHomeDataUseCase = testUseCaseWithoutReminders() apolloClient.registerTestResponse( - HomeQuery(true), + HomeQuery(true, true), HomeQuery.Data(OctopusFakeResolver) { currentMember = buildMember { claimsActive = listOf( @@ -293,7 +293,7 @@ internal class GetHomeUseCaseTest { val getHomeDataUseCase = testUseCaseWithoutReminders() apolloClient.registerTestResponse( - HomeQuery(true), + HomeQuery(true, true), HomeQuery.Data(OctopusFakeResolver) { currentMember = buildMember { claimsActive = emptyList() @@ -322,7 +322,7 @@ internal class GetHomeUseCaseTest { val getHomeDataUseCase = testUseCaseWithoutReminders() apolloClient.registerTestResponse( - HomeQuery(true), + HomeQuery(true, true), HomeQuery.Data(OctopusFakeResolver) { currentMember = buildMember { activeContracts = emptyList() @@ -369,7 +369,7 @@ internal class GetHomeUseCaseTest { testClock.now().toLocalDateTime(timeZone).date } apolloClient.registerTestResponse( - HomeQuery(true), + HomeQuery(true, true), HomeQuery.Data(OctopusFakeResolverWithFilledLists) { currentMember = buildMember { activeContracts = listOf( @@ -411,7 +411,7 @@ internal class GetHomeUseCaseTest { val getHomeDataUseCase = testUseCaseWithoutReminders(featureManager) apolloClient.registerTestResponse( - HomeQuery(true), + HomeQuery(true, true), HomeQuery.Data(OctopusFakeResolver) { currentMember = buildMember { activeContracts = emptyList() @@ -457,13 +457,18 @@ internal class GetHomeUseCaseTest { mapOf( // 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, ), ) val getHomeDataUseCase = testUseCaseWithoutReminders(featureManager) apolloClient.registerTestResponse( - HomeQuery(true), - HomeQuery.Data(OctopusFakeResolver), + HomeQuery(true, false), + HomeQuery.Data(OctopusFakeResolver) { + currentMember = buildMember { + resumableClaimIntent = null + } + }, ) apolloClient.registerTestResponse( UnreadMessageCountQuery(), @@ -509,6 +514,11 @@ internal class GetHomeUseCaseTest { isFalse() } } + assertThat(result) + .isNotNull() + .isRight() + .prop(HomeData::draftClaim) + .isNull() } @Test @@ -519,12 +529,13 @@ internal class GetHomeUseCaseTest { val featureManager = FakeFeatureManager( mapOf( Feature.ENABLE_NEW_CONVERSATION_FROM_INBOX to inboxAlwaysAvailable, + Feature.ENABLE_CLAIM_INTENT_RESUME to false, ), ) val getHomeDataUseCase = testUseCaseWithoutReminders(featureManager) apolloClient.registerTestResponse( - HomeQuery(true), + HomeQuery(true, false), HomeQuery.Data(OctopusFakeResolver), ) apolloClient.registerTestResponse( @@ -579,12 +590,13 @@ internal class GetHomeUseCaseTest { mapOf( // 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, ), ) val getHomeDataUseCase = testUseCaseWithoutReminders(featureManager) apolloClient.registerTestResponse( - HomeQuery(true), + HomeQuery(true, false), HomeQuery.Data(OctopusFakeResolver), ) apolloClient.registerTestResponse( @@ -636,7 +648,7 @@ internal class GetHomeUseCaseTest { val getHomeDataUseCase = testUseCaseWithoutReminders(featureManager) apolloClient.registerTestResponse( - HomeQuery(true), + HomeQuery(true, true), HomeQuery.Data(OctopusFakeResolver), ) apolloClient.registerTestResponse( @@ -679,7 +691,7 @@ internal class GetHomeUseCaseTest { val getHomeDataUseCase = testUseCaseWithoutReminders() apolloClient.registerTestResponse( - HomeQuery(true), + HomeQuery(true, true), HomeQuery.Data(OctopusFakeResolver) { currentMember = buildMember { crossSellV2 = buildCrossSellV2 { 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 c72f1a1d33..fb65a87312 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 @@ -15,14 +15,17 @@ import com.google.testing.junit.testparameterinjector.TestParameter 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.crosssells.CrossSellSheetData import com.hedvig.android.crosssells.RecommendedAddon import com.hedvig.android.crosssells.RecommendedCrossSell +import com.hedvig.android.data.claimintent.DeleteClaimIntentDraftUseCase 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.SeenImportantMessagesStorageImpl +import com.hedvig.android.logger.TestLogcatLoggingRule import com.hedvig.android.memberreminders.MemberReminder import com.hedvig.android.memberreminders.MemberReminders import com.hedvig.android.molecule.test.test @@ -34,11 +37,14 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.test.runTest +import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @RunWith(TestParameterInjector::class) internal class HomePresenterTest { + @get:Rule + val testLogcatLogger = TestLogcatLoggingRule() val testCrossSell = RecommendedCrossSell( crossSell = CrossSell( "id", @@ -76,6 +82,7 @@ internal class HomePresenterTest { FakeCrossSellHomeNotificationService(), ApplicationScope(backgroundScope), false, + TestDeleteClaimIntentDraftUseCase(), ) homePresenter.test(HomeUiState.Loading) { @@ -103,6 +110,7 @@ internal class HomePresenterTest { FakeCrossSellHomeNotificationService(), ApplicationScope(backgroundScope), false, + TestDeleteClaimIntentDraftUseCase(), ) homePresenter.test(HomeUiState.Loading) { @@ -128,6 +136,7 @@ internal class HomePresenterTest { FakeCrossSellHomeNotificationService(), ApplicationScope(backgroundScope), false, + TestDeleteClaimIntentDraftUseCase(), ) homePresenter.test(HomeUiState.Loading) { @@ -156,6 +165,7 @@ internal class HomePresenterTest { crossSells = CrossSellSheetData(testCrossSell, listOf(), null), firstVetSections = listOf(), travelBannerInfo = null, + draftClaim = null, ).right(), ) assertThat(awaitItem()).isEqualTo( @@ -187,6 +197,7 @@ internal class HomePresenterTest { hasUnseenChatMessages = false, addonBannerInfo = null, isProduction = false, + draftClaim = null, ), ) } @@ -201,6 +212,7 @@ internal class HomePresenterTest { FakeCrossSellHomeNotificationService(), ApplicationScope(backgroundScope), false, + TestDeleteClaimIntentDraftUseCase(), ) val addonOnlyCrossSells = CrossSellSheetData(null, listOf(), testAddon) @@ -219,6 +231,7 @@ internal class HomePresenterTest { firstVetSections = listOf(), showHelpCenter = false, travelBannerInfo = null, + draftClaim = null, ).right(), ) assertThat(awaitItem()) @@ -242,6 +255,7 @@ internal class HomePresenterTest { FakeCrossSellHomeNotificationService(), ApplicationScope(backgroundScope), false, + TestDeleteClaimIntentDraftUseCase(), ) homePresenter.test(HomeUiState.Loading) { @@ -261,6 +275,7 @@ internal class HomePresenterTest { firstVetSections = listOf(), showHelpCenter = false, travelBannerInfo = null, + draftClaim = null, ).right(), ) assertThat(awaitItem()).isEqualTo( @@ -279,6 +294,7 @@ internal class HomePresenterTest { crossSellsAction = null, addonBannerInfo = null, isProduction = false, + draftClaim = null, ), ) } @@ -293,6 +309,7 @@ internal class HomePresenterTest { FakeCrossSellHomeNotificationService(), ApplicationScope(backgroundScope), false, + TestDeleteClaimIntentDraftUseCase(), ) homePresenter.test(HomeUiState.Loading) { @@ -317,6 +334,7 @@ internal class HomePresenterTest { FakeCrossSellHomeNotificationService(), ApplicationScope(backgroundScope), false, + TestDeleteClaimIntentDraftUseCase(), ) homePresenter.test(HomeUiState.Loading) { @@ -336,6 +354,7 @@ internal class HomePresenterTest { firstVetSections = listOf(), crossSells = CrossSellSheetData(null, listOf(), null), travelBannerInfo = null, + draftClaim = null, ).right(), ) assertThat(awaitItem()) @@ -354,6 +373,7 @@ internal class HomePresenterTest { FakeCrossSellHomeNotificationService(), ApplicationScope(backgroundScope), false, + TestDeleteClaimIntentDraftUseCase(), ) homePresenter.test(HomeUiState.Loading) { @@ -371,6 +391,7 @@ internal class HomePresenterTest { firstVetSections = listOf(), showHelpCenter = false, travelBannerInfo = null, + draftClaim = null, ).right(), ) assertThat(awaitItem()).isEqualTo( @@ -387,6 +408,7 @@ internal class HomePresenterTest { crossSellsAction = null, addonBannerInfo = null, isProduction = false, + draftClaim = null, ), ) } @@ -401,6 +423,7 @@ internal class HomePresenterTest { FakeCrossSellHomeNotificationService(), ApplicationScope(backgroundScope), false, + TestDeleteClaimIntentDraftUseCase(), ) val firstVet = FirstVetSection( buttonTitle = "ButtonTitle", @@ -425,6 +448,7 @@ internal class HomePresenterTest { ), showHelpCenter = false, travelBannerInfo = null, + draftClaim = null, ).right(), ) assertThat(awaitItem()).isEqualTo( @@ -441,6 +465,7 @@ internal class HomePresenterTest { crossSellsAction = null, addonBannerInfo = null, isProduction = false, + draftClaim = null, ), ) } @@ -455,6 +480,7 @@ internal class HomePresenterTest { FakeCrossSellHomeNotificationService(), ApplicationScope(backgroundScope), false, + TestDeleteClaimIntentDraftUseCase(), ) val crossSell = CrossSell( id = "id", @@ -478,6 +504,7 @@ internal class HomePresenterTest { firstVetSections = listOf(), showHelpCenter = false, travelBannerInfo = null, + draftClaim = null, ).right(), ) assertThat(awaitItem()).isEqualTo( @@ -498,6 +525,7 @@ internal class HomePresenterTest { ), addonBannerInfo = null, isProduction = false, + draftClaim = null, ), ) } @@ -512,6 +540,7 @@ internal class HomePresenterTest { FakeCrossSellHomeNotificationService(), ApplicationScope(backgroundScope), false, + TestDeleteClaimIntentDraftUseCase(), ) homePresenter.test(HomeUiState.Loading) { assertThat(awaitItem()).isEqualTo(HomeUiState.Loading) @@ -528,6 +557,7 @@ internal class HomePresenterTest { firstVetSections = listOf(), showHelpCenter = false, travelBannerInfo = null, + draftClaim = null, ).right(), ) assertThat(awaitItem()).isEqualTo( @@ -544,6 +574,7 @@ internal class HomePresenterTest { crossSellsAction = null, addonBannerInfo = null, isProduction = false, + draftClaim = null, ), ) } @@ -558,6 +589,7 @@ internal class HomePresenterTest { FakeCrossSellHomeNotificationService(), ApplicationScope(backgroundScope), false, + TestDeleteClaimIntentDraftUseCase(), ) homePresenter.test(HomeUiState.Loading) { assertThat(awaitItem()).isEqualTo(HomeUiState.Loading) @@ -574,6 +606,7 @@ internal class HomePresenterTest { firstVetSections = listOf(), showHelpCenter = false, travelBannerInfo = null, + draftClaim = null, ).right(), ) assertThat(awaitItem()).isEqualTo( @@ -590,11 +623,67 @@ internal class HomePresenterTest { crossSellsAction = null, addonBannerInfo = null, isProduction = false, + draftClaim = null, ), ) } } + @Test + fun `deleting the draft claim calls the use case and reloads home on success`() = runTest { + val getHomeDataUseCase = TestGetHomeDataUseCase() + val deleteClaimIntentDraftUseCase = TestDeleteClaimIntentDraftUseCase() + val homePresenter = HomePresenter( + getHomeDataUseCase, + SeenImportantMessagesStorageImpl(), + FakeCrossSellHomeNotificationService(), + ApplicationScope(backgroundScope), + false, + deleteClaimIntentDraftUseCase, + ) + homePresenter.test(HomeUiState.Loading) { + assertThat(awaitItem()).isEqualTo(HomeUiState.Loading) + assertThat(getHomeDataUseCase.forceNetworkFetchTurbine.awaitItem()).isFalse() + getHomeDataUseCase.responseTurbine.add( + someIrrelevantHomeDataInstance.copy( + draftClaim = HomeData.DraftClaim("draft-id", "My things", Instant.parse("2026-07-01T00:00:00Z")), + ).right(), + ) + assertThat(awaitItem()).isInstanceOf() + + sendEvent(HomeEvent.DeleteDraftClaim("draft-id")) + assertThat(deleteClaimIntentDraftUseCase.deletedIdsTurbine.awaitItem()).isEqualTo("draft-id") + assertThat(getHomeDataUseCase.forceNetworkFetchTurbine.awaitItem()).isTrue() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `a failed draft deletion does not reload home`() = runTest { + val getHomeDataUseCase = TestGetHomeDataUseCase() + val deleteClaimIntentDraftUseCase = TestDeleteClaimIntentDraftUseCase().apply { + result = ErrorMessage().left() + } + val homePresenter = HomePresenter( + getHomeDataUseCase, + SeenImportantMessagesStorageImpl(), + FakeCrossSellHomeNotificationService(), + ApplicationScope(backgroundScope), + false, + deleteClaimIntentDraftUseCase, + ) + homePresenter.test(HomeUiState.Loading) { + assertThat(awaitItem()).isEqualTo(HomeUiState.Loading) + assertThat(getHomeDataUseCase.forceNetworkFetchTurbine.awaitItem()).isFalse() + getHomeDataUseCase.responseTurbine.add(someIrrelevantHomeDataInstance.right()) + assertThat(awaitItem()).isInstanceOf() + + sendEvent(HomeEvent.DeleteDraftClaim("draft-id")) + assertThat(deleteClaimIntentDraftUseCase.deletedIdsTurbine.awaitItem()).isEqualTo("draft-id") + getHomeDataUseCase.forceNetworkFetchTurbine.expectNoEvents() + } + } + private class TestGetHomeDataUseCase : GetHomeDataUseCase { val forceNetworkFetchTurbine = Turbine() val responseTurbine = Turbine>() @@ -616,6 +705,7 @@ internal class HomePresenterTest { firstVetSections = listOf(), crossSells = CrossSellSheetData(null, emptyList(), null), travelBannerInfo = null, + draftClaim = null, ) } @@ -634,3 +724,13 @@ private class FakeCrossSellHomeNotificationService : CrossSellHomeNotificationSe override suspend fun setLastEpochDayNewRecommendationNotificationWasShown(epochDay: Long) { } } + +private class TestDeleteClaimIntentDraftUseCase : DeleteClaimIntentDraftUseCase { + val deletedIdsTurbine = Turbine() + var result: Either = Unit.right() + + override suspend fun invoke(id: String): Either { + deletedIdsTurbine.add(id) + return result + } +} 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 365edbcc1b..cc610ec0dd 100644 --- a/app/featureflags/feature-flags/src/androidMain/kotlin/com/hedvig/android/featureflags/flags/FeatureUnleashKey.kt +++ b/app/featureflags/feature-flags/src/androidMain/kotlin/com/hedvig/android/featureflags/flags/FeatureUnleashKey.kt @@ -2,7 +2,8 @@ package com.hedvig.android.featureflags.flags internal val Feature.unleashKey: String get() = when (this) { + Feature.DISABLE_PUPPY_GUIDE -> "disable_puppy_guide" + Feature.ENABLE_CLAIM_INTENT_RESUME -> "enable_claim_intent_resume" Feature.ENABLE_NEW_CONVERSATION_FROM_INBOX -> "enable_new_conversation_from_inbox" Feature.UPDATE_NECESSARY -> "update_necessary" - Feature.DISABLE_PUPPY_GUIDE -> "disable_puppy_guide" } 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 38a8d870ed..bcd389b963 100644 --- a/app/featureflags/feature-flags/src/commonMain/kotlin/com/hedvig/android/featureflags/flags/Feature.kt +++ b/app/featureflags/feature-flags/src/commonMain/kotlin/com/hedvig/android/featureflags/flags/Feature.kt @@ -4,6 +4,13 @@ enum class Feature( // Used to easier get a context of what it's for. @Suppress("unused") val explanation: String, ) { + DISABLE_PUPPY_GUIDE( + "Kill switch for the puppy guide in the help center. When the toggle is on, the puppy guide 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.", + ), ENABLE_NEW_CONVERSATION_FROM_INBOX( "Enables inbox icon always available on the Home screen " + "and New conversation button inside the inbox", @@ -11,7 +18,4 @@ enum class Feature( UPDATE_NECESSARY( "Defines the lowest supported app version. Should prompt a user to update if it uses an outdated version.", ), - DISABLE_PUPPY_GUIDE( - "Kill switch for the puppy guide in the help center. When the toggle is on, the puppy guide is hidden.", - ), } diff --git a/app/ui/claim-status/src/main/kotlin/com/hedvig/android/ui/claimstatus/ClaimStatusCards.kt b/app/ui/claim-status/src/main/kotlin/com/hedvig/android/ui/claimstatus/ClaimStatusCards.kt index b363437892..7e2a3e520b 100644 --- a/app/ui/claim-status/src/main/kotlin/com/hedvig/android/ui/claimstatus/ClaimStatusCards.kt +++ b/app/ui/claim-status/src/main/kotlin/com/hedvig/android/ui/claimstatus/ClaimStatusCards.kt @@ -20,6 +20,7 @@ import com.hedvig.android.design.system.hedvig.HedvigPreview import com.hedvig.android.design.system.hedvig.HedvigTheme import com.hedvig.android.design.system.hedvig.LocalContentColor import com.hedvig.android.design.system.hedvig.Surface +import com.hedvig.android.ui.claimstatus.model.ClaimCardUiState import com.hedvig.android.ui.claimstatus.model.ClaimPillType.Claim import com.hedvig.android.ui.claimstatus.model.ClaimPillType.Closed.NotCompensated import com.hedvig.android.ui.claimstatus.model.ClaimProgressSegment @@ -31,18 +32,22 @@ import kotlin.time.Instant @Composable fun ClaimStatusCards( onClick: (claimId: String) -> Unit, - claimStatusCardsUiState: NonEmptyList, + onContinueDraftClaim: () -> Unit, + onDeleteDraftClaim: (draftId: String) -> Unit, + claimCardsUiState: NonEmptyList, contentPadding: PaddingValues, modifier: Modifier = Modifier, ) { - if (claimStatusCardsUiState.size == 1) { - ClaimStatusCard( - uiState = claimStatusCardsUiState.first(), + if (claimCardsUiState.size == 1) { + ClaimCard( + uiState = claimCardsUiState.first(), onClick = onClick, + onContinueDraftClaim = onContinueDraftClaim, + onDeleteDraftClaim = onDeleteDraftClaim, modifier = modifier.padding(contentPadding), ) } else { - val pagerState = rememberPagerState(pageCount = { claimStatusCardsUiState.size }) + val pagerState = rememberPagerState(pageCount = { claimCardsUiState.size }) Column(modifier) { HorizontalPager( state = pagerState, @@ -51,18 +56,18 @@ fun ClaimStatusCards( pageSpacing = 8.dp, modifier = Modifier.fillMaxWidth().systemGestureExclusion(), ) { page: Int -> - val claimStatusUiState = claimStatusCardsUiState[page] - ClaimStatusCard( - uiState = claimStatusUiState, + ClaimCard( + uiState = claimCardsUiState[page], onClick = onClick, + onContinueDraftClaim = onContinueDraftClaim, + onDeleteDraftClaim = onDeleteDraftClaim, modifier = Modifier.fillMaxWidth(), ) } Spacer(Modifier.height(16.dp)) - HorizontalPagerIndicator( pagerState = pagerState, - pageCount = claimStatusCardsUiState.size, + pageCount = claimCardsUiState.size, activeColor = LocalContentColor.current, modifier = Modifier.padding(contentPadding).align(Alignment.CenterHorizontally), ) @@ -70,6 +75,30 @@ fun ClaimStatusCards( } } +@Composable +private fun ClaimCard( + uiState: ClaimCardUiState, + onClick: (claimId: String) -> Unit, + onContinueDraftClaim: () -> Unit, + onDeleteDraftClaim: (draftId: String) -> Unit, + modifier: Modifier = Modifier, +) { + when (uiState) { + is ClaimCardUiState.Claim -> ClaimStatusCard( + uiState = uiState.uiState, + onClick = onClick, + modifier = modifier, + ) + + is ClaimCardUiState.Draft -> DraftClaimCard( + uiState = uiState, + onContinueClick = onContinueDraftClaim, + onDeleteClick = { onDeleteDraftClaim(uiState.id) }, + modifier = modifier, + ) + } +} + @HedvigPreview @Composable private fun PreviewClaimStatusCards() { @@ -77,19 +106,36 @@ private fun PreviewClaimStatusCards() { Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { ClaimStatusCards( onClick = {}, + onContinueDraftClaim = {}, + onDeleteDraftClaim = {}, contentPadding = PaddingValues(horizontal = 16.dp), - claimStatusCardsUiState = List(3) { - ClaimStatusCardUiState( - id = "id#$it", - pillTypes = listOf(Claim, NotCompensated), - claimProgressItemsUiState = listOf( - ClaimProgressSegment(Closed, SegmentType.INACTIVE), + claimCardsUiState = listOf( + ClaimCardUiState.Draft("id", "My things", Instant.parse("2026-07-02T00:00:00Z")), + ClaimCardUiState.Claim( + ClaimStatusCardUiState( + id = "id#0", + pillTypes = listOf(Claim, NotCompensated), + claimProgressItemsUiState = listOf( + ClaimProgressSegment(Closed, SegmentType.INACTIVE), + ), + claimType = "Broken item", + insuranceDisplayName = "Home Insurance Homeowner", + submittedDate = Instant.parse("2024-05-01T00:00:00Z"), + ), + ), + ClaimCardUiState.Claim( + ClaimStatusCardUiState( + id = "id#1", + pillTypes = listOf(Claim, NotCompensated), + claimProgressItemsUiState = listOf( + ClaimProgressSegment(Closed, SegmentType.INACTIVE), + ), + claimType = "Broken item", + insuranceDisplayName = "Home Insurance Homeowner", + submittedDate = Instant.parse("2024-05-01T00:00:00Z"), ), - claimType = "Broken item", - insuranceDisplayName = "Home Insurance Homeowner", - submittedDate = Instant.parse("2024-05-01T00:00:00Z"), - ) - }.toNonEmptyListOrNull()!!, + ), + ).toNonEmptyListOrNull()!!, ) } } diff --git a/app/ui/claim-status/src/main/kotlin/com/hedvig/android/ui/claimstatus/DraftClaimCard.kt b/app/ui/claim-status/src/main/kotlin/com/hedvig/android/ui/claimstatus/DraftClaimCard.kt new file mode 100644 index 0000000000..3a3579e3f4 --- /dev/null +++ b/app/ui/claim-status/src/main/kotlin/com/hedvig/android/ui/claimstatus/DraftClaimCard.kt @@ -0,0 +1,118 @@ +package com.hedvig.android.ui.claimstatus + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.hedvig.android.design.system.hedvig.ButtonDefaults.ButtonSize.Medium +import com.hedvig.android.design.system.hedvig.ButtonDefaults.ButtonStyle.Primary +import com.hedvig.android.design.system.hedvig.HedvigButton +import com.hedvig.android.design.system.hedvig.HedvigCard +import com.hedvig.android.design.system.hedvig.HedvigDateTimeFormatterDefaults +import com.hedvig.android.design.system.hedvig.HedvigPreview +import com.hedvig.android.design.system.hedvig.HedvigSecondaryRedTextButton +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.HighlightLabel +import com.hedvig.android.design.system.hedvig.HighlightLabelDefaults.HighLightSize +import com.hedvig.android.design.system.hedvig.HighlightLabelDefaults.HighlightColor +import com.hedvig.android.design.system.hedvig.HighlightLabelDefaults.HighlightShade.MEDIUM +import com.hedvig.android.design.system.hedvig.Surface +import com.hedvig.android.design.system.hedvig.datepicker.getLocale +import com.hedvig.android.ui.claimstatus.internal.ClaimProgressRow +import com.hedvig.android.ui.claimstatus.model.ClaimCardUiState +import com.hedvig.android.ui.claimstatus.model.ClaimProgressSegment +import com.hedvig.android.ui.claimstatus.model.ClaimProgressSegment.SegmentText +import com.hedvig.android.ui.claimstatus.model.ClaimProgressSegment.SegmentType.INACTIVE +import hedvig.resources.RESUME_CLAIM_CONTINUE_BUTTON +import hedvig.resources.RESUME_CLAIM_DELETE_BUTTON +import hedvig.resources.RESUME_CLAIM_DRAFT +import hedvig.resources.RESUME_CLAIM_FALLBACK_TITLE +import hedvig.resources.RESUME_CLAIM_STATED +import hedvig.resources.Res +import kotlin.time.Instant +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import org.jetbrains.compose.resources.stringResource + +@Composable +fun DraftClaimCard( + uiState: ClaimCardUiState.Draft, + onContinueClick: () -> Unit, + onDeleteClick: () -> Unit, + modifier: Modifier = Modifier, +) { + HedvigCard(modifier = modifier) { + Column(Modifier.padding(16.dp)) { + HighlightLabel( + labelText = stringResource(Res.string.RESUME_CLAIM_DRAFT), + size = HighLightSize.Small, + color = HighlightColor.Amber(MEDIUM), + ) + Spacer(Modifier.height(16.dp)) + HedvigText( + text = uiState.title ?: stringResource(Res.string.RESUME_CLAIM_FALLBACK_TITLE), + style = HedvigTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = 2.dp), + ) + val formattedDate = HedvigDateTimeFormatterDefaults + .dateMonthAndYear(getLocale()) + .format(uiState.startedAt.toLocalDateTime(TimeZone.currentSystemDefault())) + HedvigText( + text = stringResource(Res.string.RESUME_CLAIM_STATED, formattedDate), + style = HedvigTheme.typography.label, + color = HedvigTheme.colorScheme.textSecondary, + modifier = Modifier.padding(horizontal = 2.dp), + ) + Spacer(Modifier.height(18.dp)) + ClaimProgressRow( + claimProgressItemsUiState = listOf( + ClaimProgressSegment(SegmentText.Submitted, INACTIVE), + ClaimProgressSegment(SegmentText.BeingHandled, INACTIVE), + ClaimProgressSegment(SegmentText.Closed, INACTIVE), + ), + ) + Spacer(Modifier.height(16.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + HedvigSecondaryRedTextButton( + text = stringResource(Res.string.RESUME_CLAIM_DELETE_BUTTON), + onClick = onDeleteClick, + buttonSize = Medium, + modifier = Modifier.weight(1f), + ) + HedvigButton( + text = stringResource(Res.string.RESUME_CLAIM_CONTINUE_BUTTON), + onClick = onContinueClick, + enabled = true, + buttonStyle = Primary, + buttonSize = Medium, + modifier = Modifier.weight(1f), + ) + } + } + } +} + +@HedvigPreview +@Composable +private fun PreviewDraftClaimCard() { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + DraftClaimCard( + uiState = ClaimCardUiState.Draft( + id = "id", + title = "My things", + startedAt = Instant.parse("2026-07-02T00:00:00Z"), + ), + onContinueClick = {}, + onDeleteClick = {}, + ) + } + } +} diff --git a/app/ui/claim-status/src/main/kotlin/com/hedvig/android/ui/claimstatus/model/ClaimCardUiState.kt b/app/ui/claim-status/src/main/kotlin/com/hedvig/android/ui/claimstatus/model/ClaimCardUiState.kt new file mode 100644 index 0000000000..8433f88dd9 --- /dev/null +++ b/app/ui/claim-status/src/main/kotlin/com/hedvig/android/ui/claimstatus/model/ClaimCardUiState.kt @@ -0,0 +1,13 @@ +package com.hedvig.android.ui.claimstatus.model + +import kotlin.time.Instant + +sealed interface ClaimCardUiState { + data class Claim(val uiState: ClaimStatusCardUiState) : ClaimCardUiState + + data class Draft( + val id: String, + val title: String?, + val startedAt: Instant, + ) : ClaimCardUiState +} diff --git a/docs/superpowers/plans/2026-07-03-resume-draft-claim.md b/docs/superpowers/plans/2026-07-03-resume-draft-claim.md new file mode 100644 index 0000000000..8b563dbcfe --- /dev/null +++ b/docs/superpowers/plans/2026-07-03-resume-draft-claim.md @@ -0,0 +1,1634 @@ +# Resume Draft Claim 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:** Bring the Android `feat-resume-claim` branch to parity with iOS PR HedvigInsurance/ugglan#2434: draft card on home, draft/delete/expired/leave dialogs, `enable_claim_intent_resume` flag gating, displayName chat title. + +**Architecture:** A new KMP `data-claim-intent` module owns the shared resumable-draft queries (get + delete) behind project-owned types. Home carries the draft in its existing Home query (flag-gated via `@include`), the claim-cards pager gains a sealed card type with a new `DraftClaimCard`, and both start-claim entry points (home + inbox) show a `DraftClaimDialog` before the pledge sheet when a draft exists. The claim chat tracks `displayName`/`resumable` through its single `handleNext` seam. + +**Tech Stack:** Kotlin/KMP, Jetpack Compose, Apollo GraphQL (octopus), Metro DI, Molecule, Navigation 3, Unleash feature flags, Lokalise compose resources. + +**Spec:** `docs/superpowers/specs/2026-07-03-resume-draft-claim-design.md` (approved). Read it for the behavioral rationale; this plan is the how. + +## Global Constraints + +- Working branch: `feat-resume-claim`. Repo root: `/Users/stylianosgakis/hedvig/apps/android_copy_2`. Run all gradle commands from the repo root. +- Unleash flag key is exactly `enable_claim_intent_resume`; enum entry `ENABLE_CLAIM_INTENT_RESUME`. No bootstrap entry. +- All user-facing copy uses the `RESUME_CLAIM_*` Lokalise keys (verified in Task 1). Exception: the "Started" progress-segment label, which has no Lokalise key; hardcode it with a `// TODO: Add "Started" / "Påbörjad" to Lokalise` comment (iOS hardcodes it too). +- Never call Timber/Log/println; use `logcat` from `:logging-public`. +- GraphQL `octopus.*` types must not leak into public signatures; map to project-owned types inside `internal` impls. +- Kotlin style: 2-space indent, trailing commas, no wildcard imports, max line 120. Run `./gradlew ktlintFormat` before every commit. +- Never use " — " (spaced em-dash) in any prose you write (commit messages, comments): use commas, colons, or parentheses. +- `ClaimIntent.createdAt` is the `DateTime` scalar, mapped to `kotlin.time.Instant` (see `app/apollo/apollo-octopus-public/build.gradle.kts:29`). All `startedAt` fields in this plan are `kotlin.time.Instant`. +- Feature modules must not depend on other feature modules. The new shared code goes in `app/data/data-claim-intent` and `app/ui/claim-status` and `app/design-system/design-system-hedvig`. +- Commit after every task. Commit messages are short imperative sentences without a `feat:` prefix (match `git log` style, e.g. "Add resume-draft-claim design spec"). + +--- + +### Task 1: Groundwork: commit schema, download and verify strings + +**Files:** +- Modify (commit only): `app/apollo/apollo-octopus-public/src/commonMain/graphql/com/hedvig/android/apollo/octopus/schema.graphqls` (already changed on disk, unstaged) +- Modify (generated): `app/core/core-resources/src/commonMain/composeResources/values*/strings.xml` (via `downloadStrings`) + +**Interfaces:** +- Produces: schema fields `ClaimIntent.resumable: Boolean!`, `Mutation.claimIntentDeleteDraft(id: ID!): Boolean!`, and string resources `Res.string.RESUME_CLAIM_*` used by every later task. + +- [ ] **Step 1: Verify and commit the schema diff** + +Run: `git -C /Users/stylianosgakis/hedvig/apps/android_copy_2 diff --stat -- app/apollo/apollo-octopus-public/src/commonMain/graphql/com/hedvig/android/apollo/octopus/schema.graphqls` +Expected: 1 file changed (the diff adds `resumable: Boolean!` on `ClaimIntent` and the `claimIntentDeleteDraft` mutation). + +```bash +cd /Users/stylianosgakis/hedvig/apps/android_copy_2 +git add app/apollo/apollo-octopus-public/src/commonMain/graphql/com/hedvig/android/apollo/octopus/schema.graphqls +git commit -m "Download schema" +``` + +- [ ] **Step 2: Download strings** + +Run: `./gradlew downloadStrings` +Expected: BUILD SUCCESSFUL; `strings.xml` files regenerate. (Requires `lokalise.properties`; if the task fails on credentials, stop and ask the user.) + +- [ ] **Step 3: Verify the RESUME_CLAIM keys arrived** + +Run: `rg -c "RESUME_CLAIM" app/core/core-resources/src/commonMain/composeResources/values/strings.xml` +Expected: a count >= 17 (keys: CONTINUE_BUTTON, DEFAULT_TITLE, DELETE_BODY, DELETE_BUTTON, DELETE_TITLE, DRAFT, DRAFT_ALERT_BODY, DRAFT_ALERT_CONTINUE, DRAFT_ALERT_START_NEW, DRAFT_ALERT_TITLE, EXPIRED_BODY, EXPIRED_TITLE, FALLBACK_TITLE, LEAVE_BODY, LEAVE_CONFIRM, LEAVE_TITLE, STATED). +If any key is missing, STOP and report to the user; do not hardcode substitutes. + +Note: `RESUME_CLAIM_STATED` is a format string ("Started %1$s" in the Android export); it takes one argument. + +- [ ] **Step 4: Commit the strings** + +```bash +git add -A app/core/core-resources +git commit -m "Download strings with RESUME_CLAIM keys" +``` + +--- + +### Task 2: Feature flag ENABLE_CLAIM_INTENT_RESUME + +**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.ENABLE_CLAIM_INTENT_RESUME` enum entry, read everywhere later via `featureManager.isFeatureEnabled(Feature.ENABLE_CLAIM_INTENT_RESUME): Flow`. + +- [ ] **Step 1: Add the enum entry** + +In `Feature.kt`, add to the `Feature` enum (alphabetical placement next to the existing entries is fine; match the existing style): + +```kotlin +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.", +), +``` + +- [ ] **Step 2: Map the Unleash key** + +In `FeatureUnleashKey.kt`, add to the `when`: + +```kotlin +Feature.ENABLE_CLAIM_INTENT_RESUME -> "enable_claim_intent_resume" +``` + +- [ ] **Step 3: Compile (an exhaustive `when` elsewhere may need the new entry)** + +Run: `./gradlew :feature-flags:compileDebugKotlinAndroid` +Expected: BUILD SUCCESSFUL. If any other module fails later on an exhaustive `when` over `Feature`, add the entry there following the file's existing pattern. + +No bootstrap change: the flag must resolve to off when Unleash was never fetched, which is the desired default (see `app/featureflags/feature-flags/FEATURE_FLAG_DEFAULTS.md`). + +- [ ] **Step 4: Commit** + +```bash +./gradlew ktlintFormat +git add -A app/featureflags +git commit -m "Add enable_claim_intent_resume feature flag" +``` + +--- + +### Task 3: New data module data-claim-intent + +**Files:** +- Create: `app/data/data-claim-intent/build.gradle.kts` +- Create: `app/data/data-claim-intent/src/commonMain/graphql/QueryResumableClaimIntent.graphql` +- Create: `app/data/data-claim-intent/src/commonMain/graphql/MutationClaimIntentDeleteDraft.graphql` +- Create: `app/data/data-claim-intent/src/commonMain/kotlin/com/hedvig/android/data/claimintent/ResumableClaimIntent.kt` +- Create: `app/data/data-claim-intent/src/commonMain/kotlin/com/hedvig/android/data/claimintent/GetResumableClaimIntentUseCase.kt` +- Create: `app/data/data-claim-intent/src/commonMain/kotlin/com/hedvig/android/data/claimintent/DeleteClaimIntentDraftUseCase.kt` + +**Interfaces:** +- Produces (public API consumed by Tasks 8, 9, 11): + +```kotlin +data class ResumableClaimIntent(val id: String, val displayName: String?, val startedAt: Instant) +interface GetResumableClaimIntentUseCase { suspend fun invoke(): Either } +interface DeleteClaimIntentDraftUseCase { suspend fun invoke(id: String): Either } +``` + +Both bound in Metro `AppScope` via `@ContributesBinding`. Type-safe accessor: `projects.dataClaimIntent`. + +The module is modeled on `app/data/data-conversations` (KMP, apollo, no test source set; the mapping is trivial and consumer behavior is tested in HomePresenterTest in Task 8). + +- [ ] **Step 1: Create the build file** + +`app/data/data-claim-intent/build.gradle.kts`: + +```kotlin +plugins { + id("hedvig.multiplatform.library") + id("hedvig.gradle.plugin") +} + +hedvig { + apollo("octopus") +} + +kotlin { + sourceSets { + commonMain.dependencies { + implementation(libs.arrow.core) + implementation(projects.apolloCore) + implementation(projects.apolloOctopusPublic) + implementation(projects.coreCommonPublic) + implementation(projects.loggingPublic) + } + } +} +``` + +The module is auto-discovered by `settings.gradle.kts` (any directory under `app/` with a `build.gradle.kts`). + +- [ ] **Step 2: Create the GraphQL operations** + +`QueryResumableClaimIntent.graphql`: + +```graphql +query ResumableClaimIntent { + currentMember { + resumableClaimIntent { + id + displayName + createdAt + } + } +} +``` + +`MutationClaimIntentDeleteDraft.graphql`: + +```graphql +mutation ClaimIntentDeleteDraft($id: ID!) { + claimIntentDeleteDraft(id: $id) +} +``` + +- [ ] **Step 3: Create the model** + +`ResumableClaimIntent.kt`: + +```kotlin +package com.hedvig.android.data.claimintent + +import kotlin.time.Instant + +data class ResumableClaimIntent( + val id: String, + val displayName: String?, + val startedAt: Instant, +) +``` + +- [ ] **Step 4: Create the get use case** + +`GetResumableClaimIntentUseCase.kt`: + +```kotlin +package com.hedvig.android.data.claimintent + +import arrow.core.Either +import arrow.core.raise.either +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.cache.normalized.FetchPolicy +import com.apollographql.apollo.cache.normalized.fetchPolicy +import com.hedvig.android.apollo.safeExecute +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.core.common.di.AppScope +import com.hedvig.android.logger.logcat +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import octopus.ResumableClaimIntentQuery + +interface GetResumableClaimIntentUseCase { + /** + * A null right side means the member has no resumable draft claim. + */ + suspend fun invoke(): Either +} + +@Inject +@ContributesBinding(AppScope::class) +internal class GetResumableClaimIntentUseCaseImpl( + private val apolloClient: ApolloClient, +) : GetResumableClaimIntentUseCase { + override suspend fun invoke(): Either { + return either { + apolloClient + .query(ResumableClaimIntentQuery()) + .fetchPolicy(FetchPolicy.NetworkOnly) + .safeExecute() + .mapLeft { error -> + logcat(operationError = error) { "GetResumableClaimIntentUseCase failed with $error" } + ErrorMessage() + } + .bind() + .currentMember + .resumableClaimIntent + ?.let { resumableClaimIntent -> + ResumableClaimIntent( + id = resumableClaimIntent.id, + displayName = resumableClaimIntent.displayName, + startedAt = resumableClaimIntent.createdAt, + ) + } + } + } +} +``` + +Note: the `logcat(operationError = ...)` overload lives next to `safeExecute` in `:apollo-core` (`com.hedvig.android.apollo`); copy the import that `GetHomeDataUseCase.kt` uses for it. If the memory-cache dependency is missing for `fetchPolicy`, add `implementation(libs.apollo.normalizedCache)` to `commonMain.dependencies`. + +- [ ] **Step 5: Create the delete use case** + +`DeleteClaimIntentDraftUseCase.kt`: + +```kotlin +package com.hedvig.android.data.claimintent + +import arrow.core.Either +import arrow.core.raise.either +import arrow.core.raise.ensure +import com.apollographql.apollo.ApolloClient +import com.hedvig.android.apollo.safeExecute +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.core.common.di.AppScope +import com.hedvig.android.logger.logcat +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import octopus.ClaimIntentDeleteDraftMutation + +interface DeleteClaimIntentDraftUseCase { + suspend fun invoke(id: String): Either +} + +@Inject +@ContributesBinding(AppScope::class) +internal class DeleteClaimIntentDraftUseCaseImpl( + private val apolloClient: ApolloClient, +) : DeleteClaimIntentDraftUseCase { + override suspend fun invoke(id: String): Either { + return either { + val deleted = apolloClient + .mutation(ClaimIntentDeleteDraftMutation(id)) + .safeExecute() + .mapLeft { error -> + logcat(operationError = error) { "DeleteClaimIntentDraftUseCase failed with $error" } + ErrorMessage() + } + .bind() + .claimIntentDeleteDraft + ensure(deleted) { ErrorMessage() } + } + } +} +``` + +- [ ] **Step 6: Compile** + +Run: `./gradlew :data-claim-intent:compileKotlinJvm` +Expected: BUILD SUCCESSFUL (Apollo codegen runs first). If the KMP target set differs from data-conversations and `compileKotlinJvm` doesn't exist, run `./gradlew :data-claim-intent:tasks --all | rg compile` and use the main compile task. + +- [ ] **Step 7: Commit** + +```bash +./gradlew ktlintFormat +git add -A app/data/data-claim-intent +git commit -m "Add data-claim-intent module with resumable draft get and delete use cases" +``` + +--- + +### Task 4: Revert StartClaimBottomSheet, add DraftClaimDialog + +**Files:** +- Modify (revert to develop): `app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/StartClaimBottomSheet.kt` +- Create: `app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/DraftClaimDialog.kt` +- Modify: `app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomeDestination.kt` (sheet call site, ~lines 230-300) +- Modify: `app/feature/feature-chat/src/main/kotlin/com/hedvig/android/feature/chat/inbox/InboxDestination.kt` (sheet call site, ~lines 130-160) + +**Interfaces:** +- Consumes: `Res.string.RESUME_CLAIM_DRAFT_ALERT_*` (Task 1). +- Produces: `StartClaimBottomSheet(state: HedvigBottomSheetState, navigateToClaimChat: () -> Unit)` (back to the develop signature; `StartClaimSheetData` is deleted) and: + +```kotlin +@Composable +fun DraftClaimDialog( + onDismissRequest: () -> Unit, + onContinueDraft: () -> Unit, + onStartNewClaim: () -> Unit, + modifier: Modifier = Modifier, +) +``` + +- [ ] **Step 1: Revert the sheet file to the develop version** + +The develop version is exactly the pre-branch state we want (sheet keyed on `Unit`, no draft button, no `StartClaimSheetData`, no stray formatting): + +```bash +git checkout develop -- app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/StartClaimBottomSheet.kt +``` + +- [ ] **Step 2: Create DraftClaimDialog** + +`DraftClaimDialog.kt`: + +```kotlin +package com.hedvig.android.design.system.hedvig + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import hedvig.resources.RESUME_CLAIM_DRAFT_ALERT_BODY +import hedvig.resources.RESUME_CLAIM_DRAFT_ALERT_CONTINUE +import hedvig.resources.RESUME_CLAIM_DRAFT_ALERT_START_NEW +import hedvig.resources.RESUME_CLAIM_DRAFT_ALERT_TITLE +import hedvig.resources.Res +import hedvig.resources.general_cancel_button +import org.jetbrains.compose.resources.stringResource + +@Composable +fun DraftClaimDialog( + onDismissRequest: () -> Unit, + onContinueDraft: () -> Unit, + onStartNewClaim: () -> Unit, + modifier: Modifier = Modifier, +) { + HedvigDialog( + onDismissRequest = onDismissRequest, + modifier = modifier, + ) { + Column { + HedvigText( + text = stringResource(Res.string.RESUME_CLAIM_DRAFT_ALERT_TITLE), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + HedvigText( + text = stringResource(Res.string.RESUME_CLAIM_DRAFT_ALERT_BODY), + textAlign = TextAlign.Center, + color = HedvigTheme.colorScheme.textSecondary, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(24.dp)) + HedvigButton( + text = stringResource(Res.string.RESUME_CLAIM_DRAFT_ALERT_CONTINUE), + onClick = onContinueDraft, + enabled = true, + buttonStyle = ButtonDefaults.ButtonStyle.Secondary, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + HedvigButton( + text = stringResource(Res.string.RESUME_CLAIM_DRAFT_ALERT_START_NEW), + onClick = onStartNewClaim, + enabled = true, + buttonStyle = ButtonDefaults.ButtonStyle.Red, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + HedvigButton( + text = stringResource(Res.string.general_cancel_button), + onClick = onDismissRequest, + enabled = true, + buttonStyle = ButtonDefaults.ButtonStyle.Ghost, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +@HedvigPreview +@Composable +private fun PreviewDraftClaimDialog() { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + DraftClaimDialog({}, {}, {}) + } + } +} +``` + +If `HedvigDialog`'s default `style` (`DialogDefaults.defaultDialogStyle`) is not `NoButtons`, pass `style = DialogDefaults.DialogStyle.NoButtons` explicitly (check `Dialog.kt:287` and the `defaultDialogStyle` value). + +- [ ] **Step 3: Fix the home call site** + +In `HomeDestination.kt`, the current block (~line 230): + +```kotlin +val resumableClaimId = (uiState as? Success)?.resumableClaimId +val startClaimBottomSheetState = rememberHedvigBottomSheetState() +StartClaimBottomSheet( + state = startClaimBottomSheetState, + navigateToOldClaim = { + navigateToClaimChat(resumableClaimId) + }, + navigateToClaimChat = { + navigateToClaimChat(null) + }, +) +``` + +becomes: + +```kotlin +val startClaimBottomSheetState = rememberHedvigBottomSheetState() +StartClaimBottomSheet( + state = startClaimBottomSheetState, + navigateToClaimChat = { + navigateToClaimChat(null) + }, +) +``` + +and further down, `openClaimFlowSheet = { startClaimBottomSheetState.show(StartClaimSheetData(resumableClaimId)) }` becomes `openClaimFlowSheet = { startClaimBottomSheetState.show(Unit) }`. Remove the now-unused `StartClaimSheetData` import. (`navigateToClaimChat` is still `(String?) -> Unit` at this point; Task 6 changes it to `(Boolean) -> Unit`. The draft dialog gets wired in Task 9.) + +- [ ] **Step 4: Fix the inbox call site** + +In `InboxDestination.kt` (~line 132): change `rememberHedvigBottomSheetState()` back to `rememberHedvigBottomSheetState()`, `startClaimBottomSheetState.show(StartClaimSheetData(null))` back to `.show(Unit)`, and delete the `navigateToOldClaim = {}` argument and the `StartClaimSheetData` import. + +- [ ] **Step 5: Compile all three modules** + +Run: `./gradlew :design-system-hedvig:compileDebugKotlinAndroid :feature-home:compileDebugKotlin :feature-chat:compileDebugKotlin` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 6: Commit** + +```bash +./gradlew ktlintFormat +git add -A app/design-system app/feature/feature-home app/feature/feature-chat +git commit -m "Revert draft entry from pledge sheet and add DraftClaimDialog" +``` + +--- + +### Task 5: Draft card in ui/claim-status + +**Files:** +- Create: `app/ui/claim-status/src/main/kotlin/com/hedvig/android/ui/claimstatus/model/ClaimCardUiState.kt` +- Create: `app/ui/claim-status/src/main/kotlin/com/hedvig/android/ui/claimstatus/DraftClaimCard.kt` +- Modify: `app/ui/claim-status/src/main/kotlin/com/hedvig/android/ui/claimstatus/ClaimStatusCards.kt` +- Modify: `app/ui/claim-status/src/main/kotlin/com/hedvig/android/ui/claimstatus/model/ClaimProgressSegment.kt` (add `Started` segment text) +- Modify: `app/ui/claim-status/src/main/kotlin/com/hedvig/android/ui/claimstatus/internal/ClaimProgressRow.kt` (map `Started`) +- Modify: `app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomeDestination.kt` (the `claimStatusCards = { ... }` block, ~line 485) + +**Interfaces:** +- Consumes: `Res.string.RESUME_CLAIM_DRAFT`, `RESUME_CLAIM_FALLBACK_TITLE`, `RESUME_CLAIM_STATED`, `RESUME_CLAIM_DELETE_BUTTON`, `RESUME_CLAIM_CONTINUE_BUTTON` (Task 1). +- Produces (consumed by Task 9): + +```kotlin +sealed interface ClaimCardUiState { + data class Claim(val uiState: ClaimStatusCardUiState) : ClaimCardUiState + data class Draft(val id: String, val title: String?, val startedAt: Instant) : ClaimCardUiState +} + +@Composable +fun ClaimStatusCards( + onClick: (claimId: String) -> Unit, + onContinueDraftClaim: () -> Unit, + onDeleteDraftClaim: (draftId: String) -> Unit, + claimCardsUiState: NonEmptyList, + contentPadding: PaddingValues, + modifier: Modifier = Modifier, +) +``` + +- [ ] **Step 1: Add the sealed card type** + +`model/ClaimCardUiState.kt`: + +```kotlin +package com.hedvig.android.ui.claimstatus.model + +import kotlin.time.Instant + +sealed interface ClaimCardUiState { + data class Claim(val uiState: ClaimStatusCardUiState) : ClaimCardUiState + + data class Draft( + val id: String, + val title: String?, + val startedAt: Instant, + ) : ClaimCardUiState +} +``` + +- [ ] **Step 2: Add the Started segment label** + +In `model/ClaimProgressSegment.kt` change the enum: + +```kotlin +enum class SegmentText { + Submitted, + BeingHandled, + Closed, + Started, +} +``` + +In `internal/ClaimProgressRow.kt`, the `when (segmentText)` at line ~80 gains: + +```kotlin +// TODO: Add "Started" / "Påbörjad" to Lokalise +Started -> "Started" +``` + +(add the `Started` import next to the existing `Submitted`/`BeingHandled`/`Closed` imports). + +- [ ] **Step 3: Create DraftClaimCard** + +`DraftClaimCard.kt`: + +```kotlin +package com.hedvig.android.ui.claimstatus + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.hedvig.android.design.system.hedvig.ButtonDefaults.ButtonSize.Medium +import com.hedvig.android.design.system.hedvig.ButtonDefaults.ButtonStyle.Primary +import com.hedvig.android.design.system.hedvig.ButtonDefaults.ButtonStyle.Secondary +import com.hedvig.android.design.system.hedvig.HedvigButton +import com.hedvig.android.design.system.hedvig.HedvigCard +import com.hedvig.android.design.system.hedvig.HedvigDateTimeFormatterDefaults +import com.hedvig.android.design.system.hedvig.HedvigPreview +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.HighlightLabel +import com.hedvig.android.design.system.hedvig.HighlightLabelDefaults.HighLightSize +import com.hedvig.android.design.system.hedvig.HighlightLabelDefaults.HighlightColor +import com.hedvig.android.design.system.hedvig.HighlightLabelDefaults.HighlightShade.MEDIUM +import com.hedvig.android.design.system.hedvig.Surface +import com.hedvig.android.design.system.hedvig.datepicker.getLocale +import com.hedvig.android.ui.claimstatus.internal.ClaimProgressRow +import com.hedvig.android.ui.claimstatus.model.ClaimCardUiState +import com.hedvig.android.ui.claimstatus.model.ClaimProgressSegment +import com.hedvig.android.ui.claimstatus.model.ClaimProgressSegment.SegmentText +import com.hedvig.android.ui.claimstatus.model.ClaimProgressSegment.SegmentType.INACTIVE +import hedvig.resources.RESUME_CLAIM_CONTINUE_BUTTON +import hedvig.resources.RESUME_CLAIM_DELETE_BUTTON +import hedvig.resources.RESUME_CLAIM_DRAFT +import hedvig.resources.RESUME_CLAIM_FALLBACK_TITLE +import hedvig.resources.RESUME_CLAIM_STATED +import hedvig.resources.Res +import kotlin.time.Instant +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import org.jetbrains.compose.resources.stringResource + +@Composable +fun DraftClaimCard( + uiState: ClaimCardUiState.Draft, + onContinueClick: () -> Unit, + onDeleteClick: () -> Unit, + modifier: Modifier = Modifier, +) { + HedvigCard(modifier = modifier) { + Column(Modifier.padding(16.dp)) { + HighlightLabel( + labelText = stringResource(Res.string.RESUME_CLAIM_DRAFT), + size = HighLightSize.Small, + color = HighlightColor.Amber(MEDIUM), + ) + Spacer(Modifier.height(16.dp)) + HedvigText( + text = uiState.title ?: stringResource(Res.string.RESUME_CLAIM_FALLBACK_TITLE), + style = HedvigTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = 2.dp), + ) + val formattedDate = HedvigDateTimeFormatterDefaults + .dateMonthAndYear(getLocale()) + .format(uiState.startedAt.toLocalDateTime(TimeZone.currentSystemDefault())) + HedvigText( + text = stringResource(Res.string.RESUME_CLAIM_STATED, formattedDate), + style = HedvigTheme.typography.label, + color = HedvigTheme.colorScheme.textSecondary, + modifier = Modifier.padding(horizontal = 2.dp), + ) + Spacer(Modifier.height(18.dp)) + ClaimProgressRow( + claimProgressItemsUiState = listOf( + ClaimProgressSegment(SegmentText.Started, INACTIVE), + ClaimProgressSegment(SegmentText.BeingHandled, INACTIVE), + ClaimProgressSegment(SegmentText.Closed, INACTIVE), + ), + ) + Spacer(Modifier.height(16.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + HedvigButton( + text = stringResource(Res.string.RESUME_CLAIM_DELETE_BUTTON), + onClick = onDeleteClick, + enabled = true, + buttonStyle = Secondary, + buttonSize = Medium, + modifier = Modifier.weight(1f), + ) + HedvigButton( + text = stringResource(Res.string.RESUME_CLAIM_CONTINUE_BUTTON), + onClick = onContinueClick, + enabled = true, + buttonStyle = Primary, + buttonSize = Medium, + modifier = Modifier.weight(1f), + ) + } + } + } +} + +@HedvigPreview +@Composable +private fun PreviewDraftClaimCard() { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + DraftClaimCard( + uiState = ClaimCardUiState.Draft( + id = "id", + title = "My things", + startedAt = Instant.parse("2026-07-02T00:00:00Z"), + ), + onContinueClick = {}, + onDeleteClick = {}, + ) + } + } +} +``` + +Verify the exact nesting of `HighLightSize`/`HighlightColor`/`HighlightShade` in `HighlightLabel.kt` (they may be top-level in the file rather than inside `HighlightLabelDefaults`); adjust imports to match, keeping `Amber` + medium shade + small size. + +- [ ] **Step 4: Rework ClaimStatusCards over the sealed type** + +Replace the two branches in `ClaimStatusCards.kt` so both the single-card and pager paths render through one private helper: + +```kotlin +@Composable +fun ClaimStatusCards( + onClick: (claimId: String) -> Unit, + onContinueDraftClaim: () -> Unit, + onDeleteDraftClaim: (draftId: String) -> Unit, + claimCardsUiState: NonEmptyList, + contentPadding: PaddingValues, + modifier: Modifier = Modifier, +) { + if (claimCardsUiState.size == 1) { + ClaimCard( + uiState = claimCardsUiState.first(), + onClick = onClick, + onContinueDraftClaim = onContinueDraftClaim, + onDeleteDraftClaim = onDeleteDraftClaim, + modifier = modifier.padding(contentPadding), + ) + } else { + val pagerState = rememberPagerState(pageCount = { claimCardsUiState.size }) + Column(modifier) { + HorizontalPager( + state = pagerState, + contentPadding = contentPadding, + beyondViewportPageCount = 1, + pageSpacing = 8.dp, + modifier = Modifier.fillMaxWidth().systemGestureExclusion(), + ) { page: Int -> + ClaimCard( + uiState = claimCardsUiState[page], + onClick = onClick, + onContinueDraftClaim = onContinueDraftClaim, + onDeleteDraftClaim = onDeleteDraftClaim, + modifier = Modifier.fillMaxWidth(), + ) + } + Spacer(Modifier.height(16.dp)) + HorizontalPagerIndicator( + pagerState = pagerState, + pageCount = claimCardsUiState.size, + activeColor = LocalContentColor.current, + modifier = Modifier.padding(contentPadding).align(Alignment.CenterHorizontally), + ) + } + } +} + +@Composable +private fun ClaimCard( + uiState: ClaimCardUiState, + onClick: (claimId: String) -> Unit, + onContinueDraftClaim: () -> Unit, + onDeleteDraftClaim: (draftId: String) -> Unit, + modifier: Modifier = Modifier, +) { + when (uiState) { + is ClaimCardUiState.Claim -> ClaimStatusCard( + uiState = uiState.uiState, + onClick = onClick, + modifier = modifier, + ) + + is ClaimCardUiState.Draft -> DraftClaimCard( + uiState = uiState, + onContinueClick = onContinueDraftClaim, + onDeleteClick = { onDeleteDraftClaim(uiState.id) }, + modifier = modifier, + ) + } +} +``` + +Update the file's preview to wrap its fake states in `ClaimCardUiState.Claim(...)` and add one `ClaimCardUiState.Draft("id", "My things", Instant.parse("2026-07-02T00:00:00Z"))` first in the list. + +- [ ] **Step 5: Update the only external caller (feature-home)** + +Run: `rg -n "ClaimStatusCards\(" app/ --type kotlin -g '!*claim-status*'` +Expected: only `HomeDestination.kt`. In its `claimStatusCards = { ... }` block (~line 485), adapt (draft callbacks stay no-ops until Task 9): + +```kotlin +claimStatusCards = { + val claimCards = uiState.claimStatusCardsData?.claimStatusCardsUiState + ?.map { ClaimCardUiState.Claim(it) } + ?.toNonEmptyListOrNull() + if (claimCards != null) { + ClaimStatusCards( + onClick = onClaimDetailCardClicked, + onContinueDraftClaim = {}, + onDeleteDraftClaim = {}, + claimCardsUiState = claimCards, + contentPadding = PaddingValues(horizontal = 16.dp) + horizontalInsets, + ) + } +}, +``` + +with imports `com.hedvig.android.ui.claimstatus.model.ClaimCardUiState` and `arrow.core.toNonEmptyListOrNull`. + +- [ ] **Step 6: Compile** + +Run: `./gradlew :claim-status:compileDebugKotlin :feature-home:compileDebugKotlin` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 7: Commit** + +```bash +./gradlew ktlintFormat +git add -A app/ui/claim-status app/feature/feature-home +git commit -m "Add draft claim card to the claim status cards pager" +``` + +--- + +### Task 6: ClaimChatKey.resumeClaim and navigation plumbing + +**Files:** +- Modify: `app/feature/feature-claim-chat/src/androidMain/kotlin/com/hedvig/feature/claim/chat/navigation/ClaimChatEntries.kt` +- Modify: `app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/ClaimChatViewModel.kt` +- Modify: `app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/ui/ClaimChatDestination.kt` +- Modify: `app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigEntryProvider.kt` +- Modify: `app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/navigation/HomeEntries.kt` +- Modify: `app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomeDestination.kt` +- Modify: `app/feature/feature-chat/src/main/kotlin/com/hedvig/android/feature/chat/navigation/CbmChatEntries.kt` +- Modify: `app/feature/feature-chat/src/main/kotlin/com/hedvig/android/feature/chat/inbox/InboxDestination.kt` + +**Interfaces:** +- Produces: `ClaimChatKey(isDevelopmentFlow: Boolean = false, messageId: String? = null, resumeClaim: Boolean = false)`; all `navigateToClaimChat` lambdas become `(resumeClaim: Boolean) -> Unit`; `ClaimChatViewModelFactory.create(developmentFlow: Boolean, resumeClaim: Boolean)`. +- Rationale: the resume query takes no id (one draft per member; iOS passes no id), and a stale id after process-death restore would be misleading. + +- [ ] **Step 1: Change the key** + +In `ClaimChatEntries.kt`: + +```kotlin +@Serializable +data class ClaimChatKey( + val isDevelopmentFlow: Boolean = false, + val messageId: String? = null, + val resumeClaim: Boolean = false, +) : HedvigNavKey +``` + +and in the `entry` block: `resumableClaimId = key.resumableClaimId,` becomes `resumeClaim = key.resumeClaim,`. + +- [ ] **Step 2: Change the ViewModel's assisted params** + +Two same-typed `@Assisted` Booleans need identifiers (the `:viewmodel-processor` propagates them; see `HedvigViewModelProcessorTest`, the `@Assisted("topicId")` test): + +```kotlin +@AssistedInject +@HedvigViewModel(ActivityRetainedScope::class) +internal class ClaimChatViewModel( + @Assisted("developmentFlow") developmentFlow: Boolean, + @Assisted("resumeClaim") resumeClaim: Boolean, + startClaimIntentUseCase: StartClaimIntentUseCase, + // ... rest unchanged ... +``` + +Thread it to the presenter: `ClaimChatPresenter(..., resumeClaim, resumeClaimUseCase)`; in `ClaimChatPresenter` replace `private val resumableClaimId: String?` with `private val resumeClaim: Boolean`, and in the initializing block replace `val isResumingClaim = resumableClaimId != null` with `val isResumingClaim = resumeClaim`. + +- [ ] **Step 3: Change the destination** + +In `ClaimChatDestination.kt`: parameter `resumableClaimId: String?` becomes `resumeClaim: Boolean`, and the resolution becomes `create(isDevelopmentFlow, resumeClaim)`. + +- [ ] **Step 4: Update all navigate lambdas** + +- `HedvigEntryProvider.kt` `addHomeEntries` wiring: + +```kotlin +navigateToClaimChat = { resumeClaim -> + backstack.add( + ClaimChatKey( + messageId = null, + isDevelopmentFlow = false, + resumeClaim = resumeClaim, + ), + ) +}, +``` + +- `HedvigEntryProvider.kt` `addChatEntries` wiring: + +```kotlin +navigateToClaimChat = { resumeClaim -> + backstack.add(ClaimChatKey(messageId = null, isDevelopmentFlow = false, resumeClaim = resumeClaim)) +}, +``` + +- `HomeEntries.kt`: `navigateToClaimChat: (String?) -> Unit` becomes `navigateToClaimChat: (resumeClaim: Boolean) -> Unit`. +- `HomeDestination.kt`: both `navigateToClaimChat: (String?) -> Unit` params become `(resumeClaim: Boolean) -> Unit`; the sheet callback becomes `navigateToClaimChat = { navigateToClaimChat(false) }`. +- `CbmChatEntries.kt`: `navigateToClaimChat: () -> Unit` becomes `navigateToClaimChat: (resumeClaim: Boolean) -> Unit` (passed through to `InboxDestination` unchanged otherwise). +- `InboxDestination.kt`: `navigateToClaimChat: () -> Unit` params become `(resumeClaim: Boolean) -> Unit`; the sheet callback body becomes `navigateToClaimChat(false)`. + +Run `rg -n "resumableClaimId" app/` afterwards; the only remaining hits must be inside `feature-claim-chat`'s presenter history (none expected) or nothing at all except `feature-home`'s data layer (`GetHomeDataUseCase.kt`, `HomePresenter.kt`, `HomePresenterTest.kt`), which Task 7 removes. + +- [ ] **Step 5: Compile and run the serialization guard** + +Run: `./gradlew :feature-claim-chat:compileDebugKotlinAndroid :feature-home:compileDebugKotlin :feature-chat:compileDebugKotlin :app:compileDevelopKotlin` +Expected: BUILD SUCCESSFUL. (If `:app:compileDevelopKotlin` is not a task, use `./gradlew :app:assembleDevelop -x lint`.) + +Run: `./gradlew :app:test --tests "*ExhaustiveBackStackSerializationTest*"` +Expected: PASS (the key is still `@Serializable` with defaults; `navKeys()` is already applied in feature-claim-chat). + +- [ ] **Step 6: Commit** + +```bash +./gradlew ktlintFormat +git add -A app/ +git commit -m "Replace ClaimChatKey resumableClaimId with resumeClaim flag" +``` + +--- + +### Task 7: Home data layer: DraftClaim in the Home query, flag-gated + +**Files:** +- Modify: `app/feature/feature-home/src/main/graphql/QueryHome.graphql` +- Modify: `app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/data/GetHomeDataUseCase.kt` +- Modify: `app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/data/GetHomeDataUseCaseDemo.kt` +- 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: `Feature.ENABLE_CLAIM_INTENT_RESUME` (Task 2). +- Produces (consumed by Tasks 8, 9): + +```kotlin +// in HomeData: +val draftClaim: DraftClaim? // replaces resumableClaimId: String? +data class DraftClaim(val id: String, val displayName: String?, val startedAt: Instant) { + fun isExpired(now: Instant): Boolean +} +// in HomeUiState.Success: +val draftClaim: HomeData.DraftClaim? // replaces resumableClaimId: String? +``` + +- [ ] **Step 1: Extend the query, flag-gated** + +`QueryHome.graphql` header and the resumable selection become: + +```graphql +query Home($claimsHistoryFlag: Boolean!, $resumeClaimEnabled: Boolean!) { + currentMember { + resumableClaimIntent @include(if: $resumeClaimEnabled) { + id + displayName + createdAt + } +``` + +(rest of the query unchanged). + +- [ ] **Step 2: Read the flag before building the query** + +In `GetHomeDataUseCase.kt`, wrap the existing `combine(...)` in a `flatMapLatest` on the flag so the query variable is available (the existing `featureManager` property is already injected): + +```kotlin +@OptIn(ExperimentalCoroutinesApi::class) +override fun invoke(forceNetworkFetch: Boolean): Flow> { + return featureManager.isFeatureEnabled(Feature.ENABLE_CLAIM_INTENT_RESUME) + .flatMapLatest { resumeClaimEnabled -> + combine( + apolloClient.query(HomeQuery(true, resumeClaimEnabled)) + .fetchPolicy(if (forceNetworkFetch) FetchPolicy.NetworkOnly else FetchPolicy.CacheAndNetwork) + .safeFlow(), + // ... the five other flows, unchanged ... + ) { + // ... existing lambda, unchanged except the HomeData construction below ... + } + } +} +``` + +Add imports `kotlinx.coroutines.ExperimentalCoroutinesApi` and `kotlinx.coroutines.flow.flatMapLatest`. In the `HomeData(...)` construction, replace `resumableClaimId = homeQueryData.currentMember.resumableClaimIntent?.id` with: + +```kotlin +draftClaim = homeQueryData.currentMember.resumableClaimIntent?.let { resumableClaimIntent -> + HomeData.DraftClaim( + id = resumableClaimIntent.id, + displayName = resumableClaimIntent.displayName, + startedAt = resumableClaimIntent.createdAt, + ) +}, +``` + +- [ ] **Step 3: Replace the HomeData field** + +In the `HomeData` data class, replace `val resumableClaimId: String?` with `val draftClaim: DraftClaim?` and add inside `HomeData`: + +```kotlin +data class DraftClaim( + val id: String, + val displayName: String?, + val startedAt: Instant, +) { + /** + * Drafts are kept for 7 days on the backend ("Your claim is automatically saved for 7 days"). + * Client-side heuristic, same as iOS. + */ + fun isExpired(now: Instant): Boolean = now > startedAt + 7.days +} +``` + +Imports: `kotlin.time.Instant`, `kotlin.time.Duration.Companion.days`. + +In `GetHomeDataUseCaseDemo.kt`: `resumableClaimId = null` becomes `draftClaim = null`. + +- [ ] **Step 4: Thread through the presenter** + +In `HomePresenter.kt`, in `HomeUiState.Success`, `SuccessData`, and both mapping spots (`fromLastState`, the `fromHomeData`-style builder), replace `resumableClaimId: String?` / `resumableClaimId = ...` with `draftClaim: HomeData.DraftClaim?` / `draftClaim = ...` (the value comes from `homeData.draftClaim` and `lastState.draftClaim`). + +- [ ] **Step 5: Fix the tests mechanically** + +In `HomePresenterTest.kt`, replace every `resumableClaimId = null` with `draftClaim = null` (17 occurrences per the branch diff; use find-replace). + +- [ ] **Step 6: Run the tests** + +Run: `./gradlew :feature-home:test` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +./gradlew ktlintFormat +git add -A app/feature/feature-home +git commit -m "Carry flag-gated DraftClaim through the home data layer" +``` + +--- + +### Task 8: HomePresenter delete-draft event (TDD) + +**Files:** +- Modify: `app/feature/feature-home/build.gradle.kts` (add `implementation(projects.dataClaimIntent)`) +- 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/HomeViewModel.kt` +- Test: `app/feature/feature-home/src/test/kotlin/com/hedvig/android/feature/home/home/ui/HomePresenterTest.kt` + +**Interfaces:** +- Consumes: `DeleteClaimIntentDraftUseCase` (Task 3), `HomeData.DraftClaim` (Task 7). +- Produces: `HomeEvent.DeleteDraftClaim(val draftId: String)`; `HomePresenter` constructor gains a trailing `deleteClaimIntentDraftUseCase: DeleteClaimIntentDraftUseCase` param (consumed by Task 9's UI wiring). + +- [ ] **Step 1: Add the module dependency** + +In `app/feature/feature-home/build.gradle.kts` dependencies block (alphabetical): `implementation(projects.dataClaimIntent)`. + +- [ ] **Step 2: Write the failing tests** + +Add to `HomePresenterTest.kt` (reuse the existing `TestGetHomeDataUseCase`, `someIrrelevantHomeDataInstance`, `homePresenter.test` pattern; every existing `HomePresenter(...)` construction in the file also gains the new last argument `TestDeleteClaimIntentDraftUseCase()`): + +```kotlin +@Test +fun `deleting the draft claim calls the use case and reloads home on success`() = runTest { + val getHomeDataUseCase = TestGetHomeDataUseCase() + val deleteClaimIntentDraftUseCase = TestDeleteClaimIntentDraftUseCase() + val homePresenter = HomePresenter( + getHomeDataUseCase, + SeenImportantMessagesStorageImpl(), + FakeCrossSellHomeNotificationService(), + ApplicationScope(backgroundScope), + false, + deleteClaimIntentDraftUseCase, + ) + homePresenter.test(HomeUiState.Loading) { + assertThat(awaitItem()).isEqualTo(HomeUiState.Loading) + assertThat(getHomeDataUseCase.forceNetworkFetchTurbine.awaitItem()).isFalse() + getHomeDataUseCase.responseTurbine.add( + someIrrelevantHomeDataInstance.copy( + draftClaim = HomeData.DraftClaim("draft-id", "My things", Instant.parse("2026-07-01T00:00:00Z")), + ).right(), + ) + assertThat(awaitItem()).isInstanceOf() + + sendEvent(HomeEvent.DeleteDraftClaim("draft-id")) + assertThat(deleteClaimIntentDraftUseCase.deletedIdsTurbine.awaitItem()).isEqualTo("draft-id") + assertThat(getHomeDataUseCase.forceNetworkFetchTurbine.awaitItem()).isTrue() + } +} + +@Test +fun `a failed draft deletion does not reload home`() = runTest { + val getHomeDataUseCase = TestGetHomeDataUseCase() + val deleteClaimIntentDraftUseCase = TestDeleteClaimIntentDraftUseCase().apply { + result = ErrorMessage().left() + } + val homePresenter = HomePresenter( + getHomeDataUseCase, + SeenImportantMessagesStorageImpl(), + FakeCrossSellHomeNotificationService(), + ApplicationScope(backgroundScope), + false, + deleteClaimIntentDraftUseCase, + ) + homePresenter.test(HomeUiState.Loading) { + assertThat(awaitItem()).isEqualTo(HomeUiState.Loading) + assertThat(getHomeDataUseCase.forceNetworkFetchTurbine.awaitItem()).isFalse() + getHomeDataUseCase.responseTurbine.add(someIrrelevantHomeDataInstance.right()) + assertThat(awaitItem()).isInstanceOf() + + sendEvent(HomeEvent.DeleteDraftClaim("draft-id")) + assertThat(deleteClaimIntentDraftUseCase.deletedIdsTurbine.awaitItem()).isEqualTo("draft-id") + getHomeDataUseCase.forceNetworkFetchTurbine.expectNoEvents() + } +} +``` + +and the fake at the bottom of the file: + +```kotlin +private class TestDeleteClaimIntentDraftUseCase : DeleteClaimIntentDraftUseCase { + val deletedIdsTurbine = Turbine() + var result: Either = Unit.right() + + override suspend fun invoke(id: String): Either { + deletedIdsTurbine.add(id) + return result + } +} +``` + +Imports needed: `com.hedvig.android.data.claimintent.DeleteClaimIntentDraftUseCase`, `com.hedvig.android.core.common.ErrorMessage`, `kotlin.time.Instant`, `arrow.core.left`. + +- [ ] **Step 3: Run the tests to verify they fail** + +Run: `./gradlew :feature-home:test --tests "*HomePresenterTest*"` +Expected: FAIL to compile ("no value passed for parameter" / unresolved `DeleteDraftClaim`). + +- [ ] **Step 4: Implement** + +`HomePresenter.kt`: + +```kotlin +internal class HomePresenter( + private val getHomeDataUseCase: GetHomeDataUseCase, + private val seenImportantMessagesStorage: SeenImportantMessagesStorage, + private val crossSellHomeNotificationService: CrossSellHomeNotificationService, + private val applicationScope: ApplicationScope, + private val isProduction: Boolean, + private val deleteClaimIntentDraftUseCase: DeleteClaimIntentDraftUseCase, +) : MoleculePresenter { +``` + +New event in `HomeEvent`: + +```kotlin +data class DeleteDraftClaim(val draftId: String) : HomeEvent +``` + +In the `CollectEvents` block: + +```kotlin +is HomeEvent.DeleteDraftClaim -> { + launch { + deleteClaimIntentDraftUseCase.invoke(homeEvent.draftId).fold( + ifLeft = { logcat(LogPriority.ERROR) { "Failed to delete draft claim: $it" } }, + ifRight = { loadIteration++ }, + ) + } +} +``` + +`HomeViewModel.kt`: add `deleteClaimIntentDraftUseCase: DeleteClaimIntentDraftUseCase` to the constructor and pass it as the presenter's last argument (Metro resolves it; no other DI wiring needed). + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `./gradlew :feature-home:test` +Expected: PASS (all of HomePresenterTest, including the two new tests). + +- [ ] **Step 6: Commit** + +```bash +./gradlew ktlintFormat +git add -A app/feature/feature-home +git commit -m "Add delete-draft-claim event to the home presenter" +``` + +--- + +### Task 9: Home UI wiring: draft card, dialogs, draft-aware start flow + +**Files:** +- Modify: `app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomeDestination.kt` + +**Interfaces:** +- Consumes: `ClaimCardUiState`/`ClaimStatusCards`/`DraftClaimCard` (Task 5), `DraftClaimDialog` (Task 4), `HomeUiState.Success.draftClaim` + `DraftClaim.isExpired` (Task 7), `HomeEvent.DeleteDraftClaim` (Task 8), `navigateToClaimChat: (Boolean) -> Unit` (Task 6), `Res.string.RESUME_CLAIM_DELETE_*` and `RESUME_CLAIM_EXPIRED_*` (Task 1). +- Produces: complete home-side behavior; nothing new for later tasks. + +- [ ] **Step 1: Add dialog state and the draft branch in HomeScreen** + +In the `HomeScreen` composable (where `startClaimBottomSheetState` lives), add: + +```kotlin +val draftClaim = (uiState as? Success)?.draftClaim +var showDraftClaimDialog by remember { mutableStateOf(false) } +var showDraftExpiredDialog by remember { mutableStateOf(false) } +var draftIdPendingDeleteConfirmation by remember { mutableStateOf(null) } +``` + +Below the `StartClaimBottomSheet(...)` call, add the three dialogs: + +```kotlin +if (showDraftClaimDialog) { + DraftClaimDialog( + onDismissRequest = { showDraftClaimDialog = false }, + onContinueDraft = { + showDraftClaimDialog = false + navigateToClaimChat(true) + }, + onStartNewClaim = { + showDraftClaimDialog = false + startClaimBottomSheetState.show(Unit) + }, + ) +} +if (showDraftExpiredDialog) { + ErrorDialog( + title = stringResource(Res.string.RESUME_CLAIM_EXPIRED_TITLE), + message = stringResource(Res.string.RESUME_CLAIM_EXPIRED_BODY), + onDismiss = { showDraftExpiredDialog = false }, + ) +} +val draftIdToDelete = draftIdPendingDeleteConfirmation +if (draftIdToDelete != null) { + HedvigAlertDialog( + title = stringResource(Res.string.RESUME_CLAIM_DELETE_TITLE), + text = stringResource(Res.string.RESUME_CLAIM_DELETE_BODY), + confirmButtonLabel = stringResource(Res.string.RESUME_CLAIM_DELETE_BUTTON), + dismissButtonLabel = stringResource(Res.string.general_cancel_button), + onDismissRequest = { draftIdPendingDeleteConfirmation = null }, + onConfirmClick = { + draftIdPendingDeleteConfirmation = null + deleteDraftClaim(draftIdToDelete) + }, + ) +} +``` + +`ErrorDialog` and `HedvigAlertDialog` come from `com.hedvig.android.design.system.hedvig` (same components the claim chat uses). + +- [ ] **Step 2: Thread the callbacks** + +- `HomeDestination` (top-level composable): create `deleteDraftClaim = { draftId: String -> viewModel.emit(HomeEvent.DeleteDraftClaim(draftId)) }` and pass it into `HomeScreen` as a new `deleteDraftClaim: (String) -> Unit` parameter (follow the pattern of the existing `reload`/`markMessageAsSeen` lambdas; if events are sent with a different method name than `emit`, match whatever `reload` uses). +- `openClaimFlowSheet` becomes draft-aware: + +```kotlin +openClaimFlowSheet = { + if (draftClaim != null) { + showDraftClaimDialog = true + } else { + startClaimBottomSheetState.show(Unit) + } +}, +``` + +- Add two new params to `HomeScreenSuccess` and thread them into the `claimStatusCards = { ... }` slot: `onContinueDraftClaim: () -> Unit` and `onDeleteDraftClaim: (String) -> Unit`, built in `HomeScreen` as: + +```kotlin +onContinueDraftClaim = { + if (draftClaim != null) { + if (draftClaim.isExpired(Clock.System.now())) { + showDraftExpiredDialog = true + } else { + navigateToClaimChat(true) + } + } +}, +onDeleteDraftClaim = { draftId -> draftIdPendingDeleteConfirmation = draftId }, +``` + +(`Clock` is `kotlin.time.Clock`, matching the `kotlin.time.Instant` in `DraftClaim`.) + +- [ ] **Step 3: Put the draft card first in the cards list** + +In the `claimStatusCards = { ... }` block (Task 5 left the callbacks as no-ops), build the combined list: + +```kotlin +claimStatusCards = { + val claimCards: NonEmptyList? = buildList { + uiState.draftClaim?.let { draftClaim -> + add(ClaimCardUiState.Draft(draftClaim.id, draftClaim.displayName, draftClaim.startedAt)) + } + uiState.claimStatusCardsData?.claimStatusCardsUiState?.forEach { add(ClaimCardUiState.Claim(it)) } + }.toNonEmptyListOrNull() + if (claimCards != null) { + ClaimStatusCards( + onClick = onClaimDetailCardClicked, + onContinueDraftClaim = onContinueDraftClaim, + onDeleteDraftClaim = onDeleteDraftClaim, + claimCardsUiState = claimCards, + contentPadding = PaddingValues(horizontal = 16.dp) + horizontalInsets, + ) + } +}, +``` + +Update the two `PreviewHomeScreen*` fakes for any new required params (previews construct `HomeUiState.Success`, which already has `draftClaim` from Task 7). + +- [ ] **Step 4: Compile and eyeball previews** + +Run: `./gradlew :feature-home:compileDebugKotlin` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 5: Commit** + +```bash +./gradlew ktlintFormat +git add -A app/feature/feature-home +git commit -m "Wire draft claim card, delete and expired dialogs on home" +``` + +--- + +### Task 10: Claim chat: displayName title, resumable-gated leave dialog + +**Files:** +- Modify: `app/feature/feature-claim-chat/build.gradle.kts` (add `implementation(projects.featureFlags)` to `commonMain.dependencies`) +- Modify: `app/feature/feature-claim-chat/src/commonMain/graphql/FragmentClaimIntent.graphql` +- Modify: `app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/ClaimIntent.kt` +- Modify: `app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/ClaimIntentExt.kt` +- Modify: `app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/ClaimChatViewModel.kt` +- Modify: `app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/ui/ClaimChatDestination.kt` + +**Interfaces:** +- Consumes: `Feature.ENABLE_CLAIM_INTENT_RESUME` (Task 2), schema `resumable` field (Task 1). +- Produces: `ClaimChatUiState.ClaimChat` gains `title: String?`, `isResumable: Boolean`, `resumeClaimEnabled: Boolean`. Internal `ClaimIntent` gains `displayName: String?`, `resumable: Boolean`. + +- [ ] **Step 1: Fragment fields** + +In `FragmentClaimIntent.graphql`, inside `fragment ClaimIntentFragment on ClaimIntent`, add two fields next to `progress`: + +```graphql + displayName + resumable +``` + +- [ ] **Step 2: Model + mapping** + +`ClaimIntent.kt`: + +```kotlin +internal data class ClaimIntent( + val id: ClaimIntentId, + val next: Next, + val progress: Float?, + val displayName: String?, + val resumable: Boolean, + val previousSteps: List, +) { +``` + +`ClaimIntentExt.kt` `toClaimIntent`: add `displayName = displayName,` and `resumable = resumable,` to the `ClaimIntent(...)` construction. + +- [ ] **Step 3: Feature flag into the presenter** + +`build.gradle.kts` commonMain deps: `implementation(projects.featureFlags)` (alphabetical). + +`ClaimChatViewModel`: add `featureManager: FeatureManager` to the constructor (a normal Metro dependency, placed with the other non-assisted params) and pass it to `ClaimChatPresenter`, which stores `private val featureManager: FeatureManager`. + +- [ ] **Step 4: Track title/isResumable through handleNext** + +In `ClaimChatPresenter.present`, next to the existing `progress` state (~line 250): + +```kotlin +var title by remember { mutableStateOf((lastState as? ClaimChatUiState.ClaimChat)?.title) } +var isResumable by remember { + mutableStateOf((lastState as? ClaimChatUiState.ClaimChat)?.isResumable ?: false) +} +val updateIntentMetadata: (ClaimIntent) -> Unit = { intent -> + progress = intent.progress + // Keep the previous title when a step comes back without one, matching iOS. + title = intent.displayName ?: title + isResumable = intent.resumable +} +val resumeClaimEnabled by remember { + featureManager.isFeatureEnabled(Feature.ENABLE_CLAIM_INTENT_RESUME) +}.collectAsState(initial = false) +``` + +(import `androidx.compose.runtime.collectAsState`, `com.hedvig.android.featureflags.FeatureManager`, `com.hedvig.android.featureflags.flags.Feature`.) + +Change the top-level `handleNext` signature (line ~1074) from `setProgress: (Float?) -> Unit` to `updateIntentMetadata: (ClaimIntent) -> Unit`, and its body's `setProgress(intent.progress)` to `updateIntentMetadata(intent)`. Update every call site from `) { progress = it }` to `, updateIntentMetadata)` (9 hits; verify with `rg -n "handleNext\(" app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/ClaimChatViewModel.kt`; they are `handleNext(steps, setOutcome, claimIntent) { progress = it }` today). + +In the initializing block, in BOTH the resume and start branches, replace `progress = claimIntent.progress` with `updateIntentMetadata(claimIntent)`. + +Extend the returned state: + +```kotlin +claimIntentId != null -> ClaimChatUiState.ClaimChat( + // ... existing args ... + title = title, + isResumable = isResumable, + resumeClaimEnabled = resumeClaimEnabled, +) +``` + +and the `ClaimChatUiState.ClaimChat` data class gains: + +```kotlin +val title: String?, +val isResumable: Boolean, +val resumeClaimEnabled: Boolean, +``` + +- [ ] **Step 5: Title and leave dialog in the destination** + +In `ClaimChatDestination.kt`: + +Title (line ~359): + +```kotlin +val legacyTitle = stringResource(Res.string.CHAT_CONVERSATION_CLAIM_TITLE) +val title = if (uiState.resumeClaimEnabled) uiState.title ?: legacyTitle else legacyTitle +``` + +Leave-confirmation gating: define next to `showCloseFlowDialog` (line ~279): + +```kotlin +// Flag on: only a resumable draft warrants a leave confirmation (it will be saved). +// Flag off: legacy behavior, always confirm. +val showLeaveConfirmation = if (uiState.resumeClaimEnabled) uiState.isResumable else true +``` + +- `NavigationEventHandler`'s `isBackEnabled = uiState.steps.size > 1` becomes `isBackEnabled = uiState.steps.size > 1 && showLeaveConfirmation`. +- The `TopAppBar` `onActionClick` condition `if (uiState.steps.size > 1)` becomes `if (uiState.steps.size > 1 && showLeaveConfirmation)`. +- The dialog itself (line ~323) becomes: + +```kotlin +if (showCloseFlowDialog) { + if (uiState.resumeClaimEnabled) { + HedvigAlertDialog( + title = stringResource(Res.string.RESUME_CLAIM_LEAVE_TITLE), + text = stringResource(Res.string.RESUME_CLAIM_LEAVE_BODY), + confirmButtonLabel = stringResource(Res.string.RESUME_CLAIM_LEAVE_CONFIRM), + onDismissRequest = { showCloseFlowDialog = false }, + onConfirmClick = navigateUp, + ) + } else { + HedvigAlertDialog( + title = stringResource(Res.string.GENERAL_ARE_YOU_SURE), + text = stringResource(Res.string.claims_alert_body), + onDismissRequest = { showCloseFlowDialog = false }, + onConfirmClick = navigateUp, + ) + } +} +``` + +(The dismiss label defaults to `GENERAL_NO`, which matches "No". This removes the hardcoded "Your answers will be saved in a draft claim" TODO line and its commented-out predecessor.) + +- [ ] **Step 6: Compile and test** + +Run: `./gradlew :feature-claim-chat:compileDebugKotlinAndroid :feature-claim-chat:test` +Expected: BUILD SUCCESSFUL, tests (if the module has any) PASS. + +- [ ] **Step 7: Commit** + +```bash +./gradlew ktlintFormat +git add -A app/feature/feature-claim-chat +git commit -m "Use displayName title and resumable-gated leave dialog in claim chat" +``` + +--- + +### Task 11: Inbox draft handling + +**Files:** +- Modify: `app/feature/feature-chat/build.gradle.kts` (add `implementation(projects.dataClaimIntent)`) +- Modify: `app/feature/feature-chat/src/main/kotlin/com/hedvig/android/feature/chat/inbox/InboxViewModel.kt` +- Modify: `app/feature/feature-chat/src/main/kotlin/com/hedvig/android/feature/chat/inbox/InboxDestination.kt` + +**Interfaces:** +- Consumes: `GetResumableClaimIntentUseCase` (Task 3), `Feature.ENABLE_CLAIM_INTENT_RESUME` (Task 2), `DraftClaimDialog` (Task 4), `navigateToClaimChat: (Boolean) -> Unit` (Task 6). +- Produces: `InboxUiState.Success` gains `hasDraftClaim: Boolean`. + +- [ ] **Step 1: Dependency** + +`app/feature/feature-chat/build.gradle.kts`: add `implementation(projects.dataClaimIntent)` (alphabetical). + +- [ ] **Step 2: Fetch the draft in InboxPresenter** + +`InboxViewModel.kt`: + +```kotlin +@Inject +@HedvigViewModel(ActivityRetainedScope::class) +internal class InboxViewModel( + getAllConversationsUseCase: GetAllConversationsUseCase, + featureManager: FeatureManager, + getResumableClaimIntentUseCase: GetResumableClaimIntentUseCase, +) : MoleculeViewModel( + initialState = InboxUiState.Loading, + presenter = InboxPresenter(getAllConversationsUseCase, featureManager, getResumableClaimIntentUseCase), + ) + +internal class InboxPresenter( + private val getAllConversationsUseCase: GetAllConversationsUseCase, + private val featureManager: FeatureManager, + private val getResumableClaimIntentUseCase: GetResumableClaimIntentUseCase, +) : MoleculePresenter { +``` + +Inside `LaunchedEffect(loadIteration)`, before the existing `combine`: + +```kotlin +val hasDraftClaim = if (featureManager.isFeatureEnabled(Feature.ENABLE_CLAIM_INTENT_RESUME).first()) { + getResumableClaimIntentUseCase.invoke().fold( + // A failed draft lookup must not block starting a claim; treat it as no draft. + ifLeft = { false }, + ifRight = { it != null }, + ) +} else { + false +} +``` + +and extend the success state construction: `currentState = InboxUiState.Success(conversations, newChatButtonAvailable, hasDraftClaim)`. `InboxUiState.Success` gains `val hasDraftClaim: Boolean`. Imports: `com.hedvig.android.data.claimintent.GetResumableClaimIntentUseCase`, `kotlinx.coroutines.flow.first`. + +- [ ] **Step 3: Show the dialog before the pledge sheet** + +In `InboxDestination.kt`'s `InboxScreen`, add next to the sheet states: + +```kotlin +var showDraftClaimDialog by remember { mutableStateOf(false) } +if (showDraftClaimDialog) { + DraftClaimDialog( + onDismissRequest = { showDraftClaimDialog = false }, + onContinueDraft = { + showDraftClaimDialog = false + navigateToClaimChat(true) + }, + onStartNewClaim = { + showDraftClaimDialog = false + startClaimBottomSheetState.show(Unit) + }, + ) +} +``` + +and change the `onStartNewClaim` callback inside the new-chat-select sheet: + +```kotlin +onStartNewClaim = { + newChatSelectBottomSheetState.dismiss() + if ((uiState as? InboxUiState.Success)?.hasDraftClaim == true) { + showDraftClaimDialog = true + } else { + startClaimBottomSheetState.show(Unit) + } +}, +``` + +Import `com.hedvig.android.design.system.hedvig.DraftClaimDialog` plus `androidx.compose.runtime.getValue/mutableStateOf/remember/setValue` as needed. + +- [ ] **Step 4: Compile and test** + +Run: `./gradlew :feature-chat:compileDebugKotlin :feature-chat:test` +Expected: BUILD SUCCESSFUL; existing tests PASS. If an `InboxPresenterTest` exists, add the fake use case there the same way Task 8 did for home; if none exists, do not create new test infrastructure (the branching logic mirrors home's, which is tested). + +- [ ] **Step 5: Commit** + +```bash +./gradlew ktlintFormat +git add -A app/feature/feature-chat +git commit -m "Show draft claim dialog from the inbox start-claim entry" +``` + +--- + +### Task 12: Cleanups and full verification + +**Files:** +- Modify: `app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/ResumeClaimUseCase.kt` +- Modify: `app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/ClaimIntentExt.kt` + +**Interfaces:** none new; this task closes out branch debt and verifies the whole feature. + +- [ ] **Step 1: Fix the copy-pasted log tag** + +In `ResumeClaimUseCase.kt`, `logcat { "StartClaimIntentUseCase error: $it" }` becomes `logcat { "ResumeClaimUseCase error: $it" }`. Also switch it to the Apollo overload for consistency: `logcat(operationError = it) { "ResumeClaimUseCase failed with $it" }` (same import as in Task 3's use cases). + +- [ ] **Step 2: Resolve the isPrepared TODO** + +In `ClaimIntentExt.kt`, the resumed-audio mapping has `isPrepared = true, // TODO: check`. Replace the comment: + +```kotlin +AudioRecordingStepState.AudioRecording.Playback( + audioPath = AudioPath.RemoteUrl(audioUrl), + isPlaying = false, + // A resumed remote recording has no local MediaPlayer to prepare; the remote audio player + // handles its own buffering, so the playback UI can show immediately. + isPrepared = true, + hasError = false, +) +``` + +Verify during manual QA (Step 5) that a resumed audio step actually plays via `PlayableAudioSource.RemoteUrl`. The `AudioRecordingBottomSheet` returning a `null` player for remote URLs is fine: that sheet is only reachable while recording, before a step was ever submitted. + +- [ ] **Step 3: Format and full test run** + +```bash +./gradlew ktlintFormat +./gradlew ktlintCheck +./gradlew test +``` + +Expected: all PASS (includes `ExhaustiveBackStackSerializationTest` and `BackstackTest` in `:app`, `HomePresenterTest`, and the feature-claim-chat tests). + +- [ ] **Step 4: Full app assembly** + +Run: `./gradlew :app:assembleDevelop` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 5: Manual QA checklist (staging build, flag on in Unleash)** + +Home refresh mechanics note: `MoleculeViewModel` uses `SharingStarted.WhileSubscribed(5.seconds)`, so leaving home for the claim chat for more than 5 seconds stops the home presenter; returning restarts it and re-runs the Home query with `CacheAndNetwork`. This is the expected refresh-on-return mechanism; verify it in item 2 and only add an explicit reload trigger if it does not hold. + +1. Start a claim, answer a step, leave via the top-bar X: "Leave claim?" dialog with "No" / "Yes, leave" appears only once a resumable step was reached. +2. Back on home: the Draft card appears first in the cards carousel (amber Draft pill, title, "Started {date}", inactive segments). If it does not appear without a manual refresh, add `viewModel.emit(HomeEvent.RefreshData)` in a `LifecycleResumeEffect` and re-verify. +3. Card "Continue": resumes the chat with previous steps replayed, no honesty pledge, title from displayName. +4. Card "Delete": "Delete draft?" confirm, then the card disappears. +5. Home "Make a claim" with a draft: "You have a draft claim" dialog; "Continue draft" resumes; "Start new claim" opens the pledge sheet; "Cancel" does nothing. +6. Inbox → new conversation → "Start claim" with a draft: same dialog behavior. +7. A draft whose current step is a deflect outcome resumes into the deflect screen. +8. Resumed audio step: remote recording plays; resumed free-text step shows the saved text. +9. Flag off in Unleash: no draft card, no draft dialogs, legacy chat title, legacy always-on leave dialog. +10. Demo mode: home loads with no draft card. + +- [ ] **Step 6: Commit** + +```bash +git add -A app/ +git commit -m "Clean up resume-claim leftovers" +``` diff --git a/docs/superpowers/specs/2026-07-03-resume-draft-claim-design.md b/docs/superpowers/specs/2026-07-03-resume-draft-claim-design.md new file mode 100644 index 0000000000..6c8a6b88dc --- /dev/null +++ b/docs/superpowers/specs/2026-07-03-resume-draft-claim-design.md @@ -0,0 +1,251 @@ +# Resume draft claim: Android parity with iOS PR #2434 + +Date: 2026-07-03 +Branch: `feat-resume-claim` +References: iOS PR HedvigInsurance/ugglan#2434 (final state as of 2026-07-03, including the +"Added flag" and "Added expired alert" commits), backend schema fields +`ClaimIntent.resumable: Boolean!` and `claimIntentDeleteDraft(id: ID!): Boolean!`, and the +`RESUME_CLAIM_*` Lokalise string batch. + +## Goal + +Bring the Android `feat-resume-claim` branch to behavioral parity with the final state of the +iOS resume-claim PR and the current backend schema. The branch already implements the resume +mechanics inside the claim chat (previous-step replay, seeding of `current*` values, the +free-text-per-step fix). This spec covers the remaining gaps: + +1. A Draft card in the home claim-cards carousel, always first, with Delete + Continue. +2. A "You have a draft claim" dialog when starting a new claim while a draft exists, + replacing the current in-sheet "Continue with the draft claim" button. +3. The claim chat leave dialog gated on the new `ClaimIntent.resumable` field, with new copy. +4. The claim chat title taken from `ClaimIntent.displayName`. +5. The inbox start-claim entry point gets the same draft handling as home. + +Decisions already made with the user: + +- Full parity with iOS, including the draft card. +- Everything is gated behind the Unleash flag `enable_claim_intent_resume`, matching iOS. +- Shared draft-claim data goes in a new small KMP data module (Approach A), not per-feature + duplication and not an observable store singleton. +- All new user-facing copy uses the `RESUME_CLAIM_*` Lokalise string resources (already + uploaded to Lokalise; run `./gradlew downloadStrings` and verify the keys are present + before implementation). No hardcoded strings. + +## Section 0: Feature flag + +Add `ENABLE_CLAIM_INTENT_RESUME` to the `Feature` enum (commonMain), mapped to the raw +Unleash key `enable_claim_intent_resume` in `Feature.unleashKey` (androidMain), per +`FEATURE_FLAG_DEFAULTS.md`. Positive polarity; no bootstrap entry needed: when Unleash has +never been fetched the flag resolves to off, which safely hides the feature (it does not +gate anything the app needs to function). + +Gating points (all matching iOS): + +- **Home:** the Home query's `resumableClaimIntent` selection gets + `@include(if: $resumeClaimEnabledFlag)`, following the existing `$claimsHistoryFlag` + variable pattern in `GetHomeDataUseCaseImpl`. Flag off means `draftClaim == null`: + no card, no draft dialog. +- **Inbox:** `InboxPresenter` skips the `GetResumableClaimIntentUseCase` fetch when the + flag is off (treats it as no draft). +- **Claim chat title:** `displayName` is used only when the flag is on; otherwise the + legacy `CHAT_CONVERSATION_CLAIM_TITLE` string. +- **Claim chat leave dialog:** flag on gives the new resumable-gated behavior (Section 4); + flag off keeps the legacy behavior of always confirming dismissal with the generic + claims alert copy (`claims_alert_body`). +- The claim chat presenter/destination reads the flag via an injected `FeatureManager` + (same pattern as other features). + +## Section 1: Schema and shared data module + +**Schema:** commit the already-downloaded `schema.graphqls` diff (adds `ClaimIntent.resumable` +and the `claimIntentDeleteDraft` mutation) as its own "Download schema" commit, matching the +existing commit convention. + +**New module `app/data/data-claim-intent`** (KMP, `hedvig.multiplatform.library` + +`hedvig.gradle.plugin`, `hedvig { apollo("octopus") }`). Auto-discovered by settings. Note: +`app/data/data-claim-flow` and `app/data/data-claim-triaging` are empty leftover directories; +this module is new, not a revival of those. + +Public API (no `octopus.*` types leak): + +```kotlin +data class ResumableClaimIntent( + val id: String, + val displayName: String?, + val startedAt: LocalDate, // from ClaimIntent.createdAt +) + +interface GetResumableClaimIntentUseCase { + suspend fun invoke(): Either // null = no draft +} + +interface DeleteClaimIntentDraftUseCase { + suspend fun invoke(id: String): Either +} +``` + +- `GetResumableClaimIntentUseCaseImpl` runs + `query ResumableClaimIntent { currentMember { resumableClaimIntent { id displayName createdAt } } }` + with `FetchPolicy.NetworkOnly`. +- `DeleteClaimIntentDraftUseCaseImpl` runs `mutation { claimIntentDeleteDraft(id: $id) }` and + `ensure`s the returned Boolean is true. +- Both impls are `internal`, bound with `@ContributesBinding(AppScope::class)`. +- `feature-home` and `feature-chat` add `implementation(projects.dataClaimIntent)`. +- `feature-claim-chat` keeps its own internal full `ResumeClaimQuery` (it needs the whole step + fragment, which is feature-internal) and does not depend on the new module. + +## Section 2: Home draft card + +**Home query** (`app/feature/feature-home/src/main/graphql/QueryHome.graphql`): extend the +existing embedded selection to `resumableClaimIntent { id displayName createdAt }`. The card +data rides along with the rest of home: one network call, refreshes with home reload. + +**`HomeData`** (`GetHomeDataUseCase.kt`): replace `resumableClaimId: String?` with +`draftClaim: DraftClaim?` where `DraftClaim(id: String, displayName: String?, startedAt: LocalDate)` +(project-owned, mapped from the query). Demo use case returns `null`. + +**Card UI in `app/ui/claim-status`** (`ClaimStatusCards.kt`): introduce a sealed list item so +the existing pager hosts both card kinds: + +```kotlin +sealed interface ClaimCardUiState { + data class Claim(val uiState: ClaimStatusCardUiState) : ClaimCardUiState + data class Draft(val id: String, val title: String?, val startedAt: LocalDate) : ClaimCardUiState +} +``` + +- `ClaimStatusCards` takes `NonEmptyList` plus `onContinueDraftClaim` and + `onDeleteDraftClaim` lambdas. Existing single-card call sites that render a claim directly + (for example claim details) keep using the inner `ClaimStatusCard` composable and are untouched. +- New `DraftClaimCard` composable, per the iOS screenshots: "Draft" highlight pill in the + amber style (`RESUME_CLAIM_DRAFT`), title = `title ?: RESUME_CLAIM_FALLBACK_TITLE` + ("Continue where you stopped", the effective iOS fallback), subtitle + `RESUME_CLAIM_STATED` formatted with the started date ("Started %1$@"), the three progress + segments ("Started", "Being handled", "Closed") rendered in the disabled/inactive style, + and a button row of Delete (secondary, `RESUME_CLAIM_DELETE_BUTTON`) + Continue (primary, + `RESUME_CLAIM_CONTINUE_BUTTON`). + +**Ordering:** home builds the pager list with the draft first, then active claims (per the +Slack thread: draft is always first, even when a claim updates). + +**Delete flow:** Delete opens a `HedvigAlertDialog` (`RESUME_CLAIM_DELETE_TITLE` / +`RESUME_CLAIM_DELETE_BODY` / cancel = `general_cancel_button`, confirm = +`RESUME_CLAIM_DELETE_BUTTON`). Confirm sends a new `HomeEvent.DeleteDraftClaim` to +`HomePresenter`, which calls `DeleteClaimIntentDraftUseCase(id)` and, on success, triggers +the existing reload path so the card disappears. On failure: log only (matches iOS's silent +catch); no new error UI. + +**Expired drafts (matches iOS "Added expired alert"):** `DraftClaim` exposes +`fun isExpired(clock: Clock): Boolean`, true when today is past `startedAt + 7 days` +(client-side, same heuristic as iOS; the backend copy says drafts live 7 days). When the +card's Continue button is tapped on an expired draft, show an alert +(`RESUME_CLAIM_EXPIRED_TITLE` / `RESUME_CLAIM_EXPIRED_BODY`, single dismiss button) instead +of navigating. Only the card's Continue checks expiry, matching iOS. + +**Continue flow (non-expired):** navigates straight into the resumed claim chat, skipping the +honesty pledge (matches iOS). + +**Refresh on return:** verify that home refetches when the user comes back from the claim chat +(iOS refetches explicitly on flow dismissal). If home does not already reload on re-entry, +trigger the reload when returning from the claim flow so a freshly abandoned claim shows its +draft card immediately. + +## Section 3: Start-claim entry points (home + inbox) + +**Design system `StartClaimBottomSheet.kt`:** + +- Revert the sheet to `HedvigBottomSheetState`; delete `StartClaimSheetData` and the + in-sheet "Continue with the draft claim" button (iOS ended up not putting resume inside the + pledge sheet). This also reverts the leftover unused parameters on `StartClaimPledgeScreen`. +- Add `DraftClaimDialog` composable next to it: a `HedvigDialog` with three stacked big buttons. + Copy: title `RESUME_CLAIM_DRAFT_ALERT_TITLE`, body `RESUME_CLAIM_DRAFT_ALERT_BODY`, buttons + `RESUME_CLAIM_DRAFT_ALERT_CONTINUE`, `RESUME_CLAIM_DRAFT_ALERT_START_NEW` (red/attention + text style, per screenshot), `general_cancel_button`. + +**Flow on both screens (matches iOS):** on tapping "Make a claim" (home) or "Start claim" +(inbox's `NewChatSelectBottomSheetContent`): + +- No draft: open the pledge sheet directly (unchanged). +- Draft exists: show `DraftClaimDialog` first. + - "Continue draft": navigate straight into the resumed claim chat (no pledge). + - "Start new claim": open the pledge sheet (plain new-claim flow). + - "Cancel": dismiss, nothing else. + +**Home wiring** (`HomeDestination.kt`): branch on `draftClaim != null` before +`startClaimBottomSheetState.show(Unit)`. + +**Inbox wiring** (`InboxViewModel.kt` / `InboxDestination.kt`): `InboxPresenter` injects +`GetResumableClaimIntentUseCase` and fetches it alongside conversations on load/reload, +exposing the draft (or just its presence) in `InboxUiState`. `InboxDestination` branches the +same way home does. `inboxEntries` and `HedvigEntryProvider` thread a resume-capable +navigate lambda. + +**Navigation key** (`ClaimChatEntries.kt`): `ClaimChatKey.resumableClaimId: String?` becomes +`resumeClaim: Boolean = false`. The resume query takes no id (a member has at most one draft; +iOS passes no id either), and a stale id after process-death restore would be misleading. +`navigateToClaimChat` lambdas change from `(String?) -> Unit` to `(resumeClaim: Boolean) -> Unit`. +`ClaimChatViewModel`/`ClaimChatPresenter` assisted param changes accordingly. + +## Section 4: Claim chat screen + +**Fragment** (`FragmentClaimIntent.graphql`): add `displayName` and `resumable` to +`ClaimIntentFragment`. Map both into the internal `ClaimIntent` model and into +`ClaimChatUiState.InProgress` as `title: String?` and `isResumable: Boolean`. + +**Title:** with the flag on, the `TopAppBar` uses `displayName` when present, falling back to +the existing `CHAT_CONVERSATION_CLAIM_TITLE` string ("My things", "My trip • Sickness" on +iOS). Flag off keeps the legacy title. + +**Leave dialog** (replaces the current always-shown dialog with hardcoded TODO text in +`ClaimChatDestination.kt`): + +- Flag on, `isResumable == true`: dialog with title `RESUME_CLAIM_LEAVE_TITLE`, body + `RESUME_CLAIM_LEAVE_BODY`, dismiss `GENERAL_NO`, confirm `RESUME_CLAIM_LEAVE_CONFIRM`. +- Flag on, `isResumable == false`: no dialog; close immediately (matches iOS's conditional + dismiss). +- Flag off: legacy behavior, always confirm with the generic claims alert copy + (`claims_alert_body`), restoring the string the branch commented out. + +**Deflect resume:** the presenter's resume path already routes `ClaimIntent.Next.Outcome` to +the outcome/deflect screen; verify against the deflect screenshots rather than adding code. + +**Cleanups on the branch:** + +- Resolve `isPrepared = true, // TODO: check` in `ClaimIntentExt.kt` for resumed remote audio: + verify remote playback via `PlayableAudioSource.RemoteUrl` and either keep the value with a + real rationale or fix it. Note `AudioRecordingBottomSheet` returns a `null` player for remote + URLs; that path is only reachable pre-submit, verify and leave as is if confirmed. +- The commented-out `// text = stringResource(Res.string.claims_alert_body)` line and its + TODO replacement disappear into the flag-dependent leave-dialog logic above. +- Run `./gradlew ktlintFormat` over the branch (style drift exists: `resumableClaimId!=null`, + missing trailing commas, odd line breaks in `StartClaimBottomSheet.kt`). + +## Testing + +- `HomePresenterTest`: update for `draftClaim` (replacing `resumableClaimId`), add coverage for + the delete-draft event (success reloads, failure keeps state) and for the flag-off case + (no draft in ui state even when the backend would return one). +- Inbox presenter test (if the existing test pattern covers `InboxPresenter`): draft presence + reaches the ui state. +- `ExhaustiveBackStackSerializationTest` picks up the `ClaimChatKey` shape change automatically. +- `feature-claim-chat` presenter test for the resume-seeding path if presenter test infra + exists there: previous steps are replayed with Task steps filtered out, and an + outcome-terminal draft resumes into the outcome. + +## Error handling summary + +- Get resumable intent fails on home: home already surfaces query errors through its existing + error state; the draft card is simply absent when the field is null. +- Get resumable intent fails on inbox: treat as "no draft" (do not block starting a claim). +- Delete draft fails: log, keep the card (silent, matches iOS). +- Draft expired at the card: expired alert on Continue (Section 2); delete still works. +- Resume query returns null (draft expired/deleted between card render and entering the + chat): the existing `failedToStart` error state in the claim chat shows; acceptable for + now (iOS has the same hole past the card check). + +## Out of scope + +- The experimental AI development flow's step-loop issue (backend-side, tracked in + #rnd-claims-automation). +- Deep links directly into a resumed claim. +- Bootstrap entry for the new flag (off-when-unfetched is the desired default). diff --git a/hedvig-lint/lint-baseline/lint-baseline-data-claim-intent.xml b/hedvig-lint/lint-baseline/lint-baseline-data-claim-intent.xml new file mode 100644 index 0000000000..f23053f17a --- /dev/null +++ b/hedvig-lint/lint-baseline/lint-baseline-data-claim-intent.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + +