diff --git a/app/apollo/apollo-octopus-public/src/commonMain/graphql/com/hedvig/android/apollo/octopus/schema.graphqls b/app/apollo/apollo-octopus-public/src/commonMain/graphql/com/hedvig/android/apollo/octopus/schema.graphqls index 9b640dcbe3..98edc25fe0 100644 --- a/app/apollo/apollo-octopus-public/src/commonMain/graphql/com/hedvig/android/apollo/octopus/schema.graphqls +++ b/app/apollo/apollo-octopus-public/src/commonMain/graphql/com/hedvig/android/apollo/octopus/schema.graphqls @@ -971,6 +971,11 @@ type ClaimIntent { """ displayName: String """ + Whether this draft is resumable by the member. A draft that has been deleted (see `claimIntentDeleteDraft`) + is no longer resumable. + """ + resumable: Boolean! + """ The created claim of this intent. Once this value is returned, the intent is effectively over, and no steps can be produced or interacted with. @@ -3713,6 +3718,10 @@ type Mutation { """ claimIntentRegretStep(stepId: ID!): ClaimIntentMutationOutput! """ + Delete a draft claim intent from the member's perspective (a soft delete) so it is no longer returned by `resumableClaimIntent`. + """ + claimIntentDeleteDraft(id: ID!): Boolean! + """ Submit a step containing a `ClaimIntentStepContentForm`. """ claimIntentSubmitForm(input: ClaimIntentSubmitFormInput!): ClaimIntentMutationOutput! 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 75a8dca92c..14e7373a3f 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 @@ -503,8 +503,8 @@ Inga konversationer än. Starta en genom att skriva ett meddelande Din inkorg är tom Nytt - Anmäl något som har hänt med dig eller dina saker - Frågor om din försäkring, betalningar och annat + Anmäl något som hänt dig eller dina saker + Frågor om din försäkring, betalningar eller annat Erbjudanden Du har ingen aktiv försäkring Tillägg @@ -714,6 +714,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? + Borjade %1$s Spara och fortsätt Inga träffar på din sökning Sök @@ -986,6 +1003,7 @@ Välj den försäkring du vill uppdatera Visa ditt skydd Välj din skyddsnivå och självrisk + Välj din skyddsnivå Godkänn ändringar Skyddet Dokument 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 0d194724d5..d0adb2eced 100644 --- a/app/core/core-resources/src/androidMain/res/values/strings.xml +++ b/app/core/core-resources/src/androidMain/res/values/strings.xml @@ -714,6 +714,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 @@ -986,6 +1003,7 @@ Select the insurance you want to edit Show coverage Set your coverage level and deductible + Set your coverage level Confirm changes Coverage Documents 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 2d75e502d1..12f10933e9 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 @@ -502,8 +502,8 @@ Inga konversationer än. Starta en genom att skriva ett meddelande Din inkorg är tom Nytt - Anmäl något som har hänt med dig eller dina saker - Frågor om din försäkring, betalningar och annat + Anmäl något som hänt dig eller dina saker + Frågor om din försäkring, betalningar eller annat Erbjudanden Du har ingen aktiv försäkring Tillägg @@ -713,6 +713,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? + Borjade %1$s Spara och fortsätt Inga träffar på din sökning Sök @@ -985,6 +1002,7 @@ Välj den försäkring du vill uppdatera Visa ditt skydd Välj din skyddsnivå och självrisk + Välj din skyddsnivå Godkänn ändringar Skyddet Dokument 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 0061307317..3849cceaa4 100644 --- a/app/core/core-resources/src/commonMain/composeResources/values/strings.xml +++ b/app/core/core-resources/src/commonMain/composeResources/values/strings.xml @@ -713,6 +713,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 @@ -985,6 +1002,7 @@ Select the insurance you want to edit Show coverage Set your coverage level and deductible + Set your coverage level Confirm changes Coverage Documents 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/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..0642f4f140 --- /dev/null +++ b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/DraftClaimDialog.kt @@ -0,0 +1,79 @@ +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({}, {}, {}) + } + } +} 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 c333001488..eb2fa3e23f 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 c4624d0ea3..e3d04b2bfe 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 @@ -31,12 +31,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 @@ -60,123 +64,138 @@ 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 crossSells = CrossSellSheetData( + recommendedCrossSell = recommendedCrossSell, + otherCrossSells = otherCrossSellsData, + ) + 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 crossSells = CrossSellSheetData( - recommendedCrossSell = recommendedCrossSell, - otherCrossSells = otherCrossSellsData, - ) - 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 { @@ -280,6 +299,7 @@ data class HomeData( val firstVetSections: List, val crossSells: CrossSellSheetData, val travelBannerInfo: AddonBannerInfo?, + val draftClaim: DraftClaim?, ) { @Immutable data class ClaimStatusCardsData( @@ -297,6 +317,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 4ee160b208..24d3a0eb39 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 @@ -56,6 +56,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 d0c15818c5..8d42febe2d 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,8 @@ 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.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 +123,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 +132,15 @@ 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_DRAFT_ALERT_START_NEW +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,61 @@ 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) { + HedvigAlertDialog( + title = stringResource(Res.string.RESUME_CLAIM_EXPIRED_TITLE), + text = stringResource(Res.string.RESUME_CLAIM_EXPIRED_BODY), + confirmButtonLabel = stringResource(Res.string.RESUME_CLAIM_DRAFT_ALERT_START_NEW), + dismissButtonLabel = stringResource(Res.string.general_cancel_button), + onDismissRequest = { showDraftExpiredDialog = false }, + onConfirmClick = { + showDraftExpiredDialog = false + // Starting a new claim from the expired notice is the member's confirmation to drop the + // stale draft, so the expired draft is deleted without a second confirmation dialog. + draftClaim?.let { deleteDraftClaim(it.id) } + startClaimBottomSheetState.show(Unit) + }, + ) + } + 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 +341,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 +506,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 +555,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, ) } @@ -796,6 +886,7 @@ private fun PreviewHomeScreen( flowType = FlowType.APP_TRAVEL_PLUS_SELL_OR_UPGRADE, ), isProduction = true, + draftClaim = null, ), notificationPermissionState = rememberPreviewNotificationPermissionState(), reload = {}, @@ -811,6 +902,7 @@ private fun PreviewHomeScreen( openAppSettings = {}, navigateToMissingInfo = { _, _ -> }, markMessageAsSeen = {}, + deleteDraftClaim = {}, navigateToFirstVet = {}, markCrossSellsNotificationAsSeen = {}, navigateToContactInfo = {}, @@ -843,6 +935,7 @@ private fun PreviewHomeScreenWithError() { openAppSettings = {}, navigateToMissingInfo = { _, _ -> }, markMessageAsSeen = {}, + deleteDraftClaim = {}, navigateToFirstVet = {}, markCrossSellsNotificationAsSeen = {}, navigateToContactInfo = {}, @@ -881,6 +974,7 @@ private fun PreviewHomeScreenAllHomeTextTypes( chatAction = ChatAction, addonBannerInfo = null, isProduction = true, + draftClaim = null, ), notificationPermissionState = rememberPreviewNotificationPermissionState(), reload = {}, @@ -896,6 +990,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 fafa5cb3bc..c5a4740a51 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, ) } @@ -257,6 +276,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 efd28833a0..088ff69b79 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 @@ -91,7 +91,7 @@ internal class GetHomeUseCaseTest { val getHomeDataUseCase = GetHomeDataUseCaseImpl( apolloClient.apply { registerTestResponse( - HomeQuery(true), + HomeQuery(true, true), HomeQuery.Data(OctopusFakeResolver), ) apolloClient.registerTestResponse( @@ -140,7 +140,7 @@ internal class GetHomeUseCaseTest { val getHomeDataUseCase = GetHomeDataUseCaseImpl( apolloClient.apply { registerTestResponse( - HomeQuery(true), + HomeQuery(true, true), HomeQuery.Data(OctopusFakeResolver), ) apolloClient.registerTestResponse( @@ -175,7 +175,7 @@ internal class GetHomeUseCaseTest { val getHomeDataUseCase = testUseCaseWithoutReminders() apolloClient.registerTestResponse( - HomeQuery(true), + HomeQuery(true, true), HomeQuery.Data(OctopusFakeResolver) { currentMember = buildMember { importantMessages = List(3) { index -> @@ -217,7 +217,7 @@ internal class GetHomeUseCaseTest { val getHomeDataUseCase = testUseCaseWithoutReminders() apolloClient.registerTestResponse( - HomeQuery(true), + HomeQuery(true, true), HomeQuery.Data(OctopusFakeResolver) { currentMember = buildMember { importantMessages = emptyList() @@ -246,7 +246,7 @@ internal class GetHomeUseCaseTest { val getHomeDataUseCase = testUseCaseWithoutReminders() apolloClient.registerTestResponse( - HomeQuery(true), + HomeQuery(true, true), HomeQuery.Data(OctopusFakeResolver) { currentMember = buildMember { claimsActive = listOf( @@ -288,7 +288,7 @@ internal class GetHomeUseCaseTest { val getHomeDataUseCase = testUseCaseWithoutReminders() apolloClient.registerTestResponse( - HomeQuery(true), + HomeQuery(true, true), HomeQuery.Data(OctopusFakeResolver) { currentMember = buildMember { claimsActive = emptyList() @@ -317,7 +317,7 @@ internal class GetHomeUseCaseTest { val getHomeDataUseCase = testUseCaseWithoutReminders() apolloClient.registerTestResponse( - HomeQuery(true), + HomeQuery(true, true), HomeQuery.Data(OctopusFakeResolver) { currentMember = buildMember { activeContracts = emptyList() @@ -364,7 +364,7 @@ internal class GetHomeUseCaseTest { testClock.now().toLocalDateTime(timeZone).date } apolloClient.registerTestResponse( - HomeQuery(true), + HomeQuery(true, true), HomeQuery.Data(OctopusFakeResolverWithFilledLists) { currentMember = buildMember { activeContracts = listOf( @@ -406,7 +406,7 @@ internal class GetHomeUseCaseTest { val getHomeDataUseCase = testUseCaseWithoutReminders(featureManager) apolloClient.registerTestResponse( - HomeQuery(true), + HomeQuery(true, true), HomeQuery.Data(OctopusFakeResolver) { currentMember = buildMember { activeContracts = emptyList() @@ -452,13 +452,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(), @@ -504,6 +509,11 @@ internal class GetHomeUseCaseTest { isFalse() } } + assertThat(result) + .isNotNull() + .isRight() + .prop(HomeData::draftClaim) + .isNull() } @Test @@ -514,12 +524,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( @@ -574,12 +585,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( @@ -631,7 +643,7 @@ internal class GetHomeUseCaseTest { val getHomeDataUseCase = testUseCaseWithoutReminders(featureManager) apolloClient.registerTestResponse( - HomeQuery(true), + HomeQuery(true, true), HomeQuery.Data(OctopusFakeResolver), ) apolloClient.registerTestResponse( 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 4f7fde4823..594cdedee8 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,13 +15,16 @@ 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.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 @@ -33,11 +36,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", @@ -63,6 +69,7 @@ internal class HomePresenterTest { FakeCrossSellHomeNotificationService(), ApplicationScope(backgroundScope), false, + TestDeleteClaimIntentDraftUseCase(), ) homePresenter.test(HomeUiState.Loading) { @@ -90,6 +97,7 @@ internal class HomePresenterTest { FakeCrossSellHomeNotificationService(), ApplicationScope(backgroundScope), false, + TestDeleteClaimIntentDraftUseCase(), ) homePresenter.test(HomeUiState.Loading) { @@ -115,6 +123,7 @@ internal class HomePresenterTest { FakeCrossSellHomeNotificationService(), ApplicationScope(backgroundScope), false, + TestDeleteClaimIntentDraftUseCase(), ) homePresenter.test(HomeUiState.Loading) { @@ -143,6 +152,7 @@ internal class HomePresenterTest { crossSells = CrossSellSheetData(testCrossSell, listOf()), firstVetSections = listOf(), travelBannerInfo = null, + draftClaim = null, ).right(), ) assertThat(awaitItem()).isEqualTo( @@ -174,6 +184,7 @@ internal class HomePresenterTest { hasUnseenChatMessages = false, addonBannerInfo = null, isProduction = false, + draftClaim = null, ), ) } @@ -188,6 +199,7 @@ internal class HomePresenterTest { FakeCrossSellHomeNotificationService(), ApplicationScope(backgroundScope), false, + TestDeleteClaimIntentDraftUseCase(), ) homePresenter.test(HomeUiState.Loading) { @@ -207,6 +219,7 @@ internal class HomePresenterTest { firstVetSections = listOf(), showHelpCenter = false, travelBannerInfo = null, + draftClaim = null, ).right(), ) assertThat(awaitItem()).isEqualTo( @@ -225,6 +238,7 @@ internal class HomePresenterTest { crossSellsAction = null, addonBannerInfo = null, isProduction = false, + draftClaim = null, ), ) } @@ -239,6 +253,7 @@ internal class HomePresenterTest { FakeCrossSellHomeNotificationService(), ApplicationScope(backgroundScope), false, + TestDeleteClaimIntentDraftUseCase(), ) homePresenter.test(HomeUiState.Loading) { @@ -263,6 +278,7 @@ internal class HomePresenterTest { FakeCrossSellHomeNotificationService(), ApplicationScope(backgroundScope), false, + TestDeleteClaimIntentDraftUseCase(), ) homePresenter.test(HomeUiState.Loading) { @@ -282,6 +298,7 @@ internal class HomePresenterTest { firstVetSections = listOf(), crossSells = CrossSellSheetData(null, listOf()), travelBannerInfo = null, + draftClaim = null, ).right(), ) assertThat(awaitItem()) @@ -300,6 +317,7 @@ internal class HomePresenterTest { FakeCrossSellHomeNotificationService(), ApplicationScope(backgroundScope), false, + TestDeleteClaimIntentDraftUseCase(), ) homePresenter.test(HomeUiState.Loading) { @@ -317,6 +335,7 @@ internal class HomePresenterTest { firstVetSections = listOf(), showHelpCenter = false, travelBannerInfo = null, + draftClaim = null, ).right(), ) assertThat(awaitItem()).isEqualTo( @@ -333,6 +352,7 @@ internal class HomePresenterTest { crossSellsAction = null, addonBannerInfo = null, isProduction = false, + draftClaim = null, ), ) } @@ -347,6 +367,7 @@ internal class HomePresenterTest { FakeCrossSellHomeNotificationService(), ApplicationScope(backgroundScope), false, + TestDeleteClaimIntentDraftUseCase(), ) val firstVet = FirstVetSection( buttonTitle = "ButtonTitle", @@ -371,6 +392,7 @@ internal class HomePresenterTest { ), showHelpCenter = false, travelBannerInfo = null, + draftClaim = null, ).right(), ) assertThat(awaitItem()).isEqualTo( @@ -387,6 +409,7 @@ internal class HomePresenterTest { crossSellsAction = null, addonBannerInfo = null, isProduction = false, + draftClaim = null, ), ) } @@ -401,6 +424,7 @@ internal class HomePresenterTest { FakeCrossSellHomeNotificationService(), ApplicationScope(backgroundScope), false, + TestDeleteClaimIntentDraftUseCase(), ) val crossSell = CrossSell( id = "id", @@ -424,6 +448,7 @@ internal class HomePresenterTest { firstVetSections = listOf(), showHelpCenter = false, travelBannerInfo = null, + draftClaim = null, ).right(), ) assertThat(awaitItem()).isEqualTo( @@ -444,6 +469,7 @@ internal class HomePresenterTest { ), addonBannerInfo = null, isProduction = false, + draftClaim = null, ), ) } @@ -458,6 +484,7 @@ internal class HomePresenterTest { FakeCrossSellHomeNotificationService(), ApplicationScope(backgroundScope), false, + TestDeleteClaimIntentDraftUseCase(), ) homePresenter.test(HomeUiState.Loading) { assertThat(awaitItem()).isEqualTo(HomeUiState.Loading) @@ -474,6 +501,7 @@ internal class HomePresenterTest { firstVetSections = listOf(), showHelpCenter = false, travelBannerInfo = null, + draftClaim = null, ).right(), ) assertThat(awaitItem()).isEqualTo( @@ -490,6 +518,7 @@ internal class HomePresenterTest { crossSellsAction = null, addonBannerInfo = null, isProduction = false, + draftClaim = null, ), ) } @@ -504,6 +533,7 @@ internal class HomePresenterTest { FakeCrossSellHomeNotificationService(), ApplicationScope(backgroundScope), false, + TestDeleteClaimIntentDraftUseCase(), ) homePresenter.test(HomeUiState.Loading) { assertThat(awaitItem()).isEqualTo(HomeUiState.Loading) @@ -520,6 +550,7 @@ internal class HomePresenterTest { firstVetSections = listOf(), showHelpCenter = false, travelBannerInfo = null, + draftClaim = null, ).right(), ) assertThat(awaitItem()).isEqualTo( @@ -536,11 +567,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>() @@ -562,6 +649,7 @@ internal class HomePresenterTest { firstVetSections = listOf(), crossSells = CrossSellSheetData(null, emptyList()), travelBannerInfo = null, + draftClaim = null, ) } @@ -580,3 +668,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..a8bd753027 --- /dev/null +++ b/app/ui/claim-status/src/main/kotlin/com/hedvig/android/ui/claimstatus/DraftClaimCard.kt @@ -0,0 +1,120 @@ +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 = {}, + ) + } + } +} diff --git a/app/ui/claim-status/src/main/kotlin/com/hedvig/android/ui/claimstatus/internal/ClaimProgressRow.kt b/app/ui/claim-status/src/main/kotlin/com/hedvig/android/ui/claimstatus/internal/ClaimProgressRow.kt index 36b5db2119..5356d34c97 100644 --- a/app/ui/claim-status/src/main/kotlin/com/hedvig/android/ui/claimstatus/internal/ClaimProgressRow.kt +++ b/app/ui/claim-status/src/main/kotlin/com/hedvig/android/ui/claimstatus/internal/ClaimProgressRow.kt @@ -25,6 +25,7 @@ import com.hedvig.android.design.system.hedvig.Surface import com.hedvig.android.ui.claimstatus.model.ClaimProgressSegment import com.hedvig.android.ui.claimstatus.model.ClaimProgressSegment.SegmentText.BeingHandled import com.hedvig.android.ui.claimstatus.model.ClaimProgressSegment.SegmentText.Closed +import com.hedvig.android.ui.claimstatus.model.ClaimProgressSegment.SegmentText.Started import com.hedvig.android.ui.claimstatus.model.ClaimProgressSegment.SegmentText.Submitted import com.hedvig.android.ui.claimstatus.model.ClaimProgressSegment.SegmentType import com.hedvig.android.ui.claimstatus.model.ClaimProgressSegment.SegmentType.ACTIVE @@ -79,8 +80,13 @@ private fun ClaimProgress( } val text = when (segmentText) { Submitted -> stringResource(Res.string.claim_status_detail_submitted) + BeingHandled -> stringResource(Res.string.claim_status_bar_being_handled) + Closed -> stringResource(Res.string.claim_status_detail_closed) + + // TODO: Add "Started" / "Påbörjad" to Lokalise + Started -> "Started" } ClaimProgress( text = text, 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/app/ui/claim-status/src/main/kotlin/com/hedvig/android/ui/claimstatus/model/ClaimProgressSegment.kt b/app/ui/claim-status/src/main/kotlin/com/hedvig/android/ui/claimstatus/model/ClaimProgressSegment.kt index 71dc5400f0..ca879b287a 100644 --- a/app/ui/claim-status/src/main/kotlin/com/hedvig/android/ui/claimstatus/model/ClaimProgressSegment.kt +++ b/app/ui/claim-status/src/main/kotlin/com/hedvig/android/ui/claimstatus/model/ClaimProgressSegment.kt @@ -13,6 +13,7 @@ data class ClaimProgressSegment( Submitted, BeingHandled, Closed, + Started, } enum class SegmentType { 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).