diff --git a/app/feature/feature-claim-chat/build.gradle.kts b/app/feature/feature-claim-chat/build.gradle.kts index 41413e0056..d9d0431c10 100644 --- a/app/feature/feature-claim-chat/build.gradle.kts +++ b/app/feature/feature-claim-chat/build.gradle.kts @@ -54,6 +54,13 @@ kotlin { implementation(projects.uiForceUpgrade) implementation(projects.partnersDeflect) } + commonTest.dependencies { + implementation(libs.assertK) + implementation(libs.kotlin.test) + } + jvmTest.dependencies { + implementation(org.jetbrains.compose.ComposePlugin.Dependencies(project).desktop.currentOs) + } androidMain.dependencies { implementation(libs.accompanist.permissions) implementation(libs.androidx.activity.compose) 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..3697e5fd33 100644 --- a/app/feature/feature-claim-chat/src/commonMain/graphql/FragmentClaimIntent.graphql +++ b/app/feature/feature-claim-chat/src/commonMain/graphql/FragmentClaimIntent.graphql @@ -25,12 +25,19 @@ fragment ClaimIntentStepContentFragment on ClaimIntentStepContent { ...SummaryFragment ...DeflectionFragment ...DeflectionMessageFragment + ...InformationFragment } fragment DeflectionMessageFragment on ClaimIntentStepContentDeflectionMessage { message } +fragment InformationFragment on ClaimIntentStepContentInformation { + notice + severity + buttonTitle +} + fragment FormFragment on ClaimIntentStepContentForm { fields { id diff --git a/app/feature/feature-claim-chat/src/commonMain/graphql/MutationClaimIntentSubmitInformation.graphql b/app/feature/feature-claim-chat/src/commonMain/graphql/MutationClaimIntentSubmitInformation.graphql new file mode 100644 index 0000000000..8864a7d857 --- /dev/null +++ b/app/feature/feature-claim-chat/src/commonMain/graphql/MutationClaimIntentSubmitInformation.graphql @@ -0,0 +1,5 @@ +mutation ClaimIntentSubmitInformation($stepId: ID!) { + claimIntentSubmitInformation(input: {stepId: $stepId}) { + ...ClaimIntentMutationOutputFragment + } +} 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..efee7c87b0 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 @@ -45,6 +45,7 @@ import com.hedvig.feature.claim.chat.data.StepId import com.hedvig.feature.claim.chat.data.SubmitAudioRecordingUseCase import com.hedvig.feature.claim.chat.data.SubmitFileUploadUseCase import com.hedvig.feature.claim.chat.data.SubmitFormUseCase +import com.hedvig.feature.claim.chat.data.SubmitInformationUseCase import com.hedvig.feature.claim.chat.data.SubmitSelectUseCase import com.hedvig.feature.claim.chat.data.SubmitSummaryUseCase import com.hedvig.feature.claim.chat.data.SubmitTaskUseCase @@ -116,6 +117,8 @@ internal sealed interface ClaimChatEvent { data class SubmitClaim(val id: StepId) : ClaimChatEvent + data class AcknowledgeInformation(val id: StepId) : ClaimChatEvent + data object HandledOutcomeNavigation : ClaimChatEvent data class HandledDeflectNavigation(val stepId: StepId) : ClaimChatEvent @@ -172,6 +175,7 @@ internal class ClaimChatViewModel( submitFormUseCase: SubmitFormUseCase, submitSelectUseCase: SubmitSelectUseCase, submitSummaryUseCase: SubmitSummaryUseCase, + submitInformationUseCase: SubmitInformationUseCase, private val audioRecordingManager: AudioRecordingManager, skipStepUseCase: SkipStepUseCase, regretStepUseCase: RegretStepUseCase, @@ -189,6 +193,7 @@ internal class ClaimChatViewModel( submitFormUseCase, submitSelectUseCase, submitSummaryUseCase, + submitInformationUseCase, skipStepUseCase, audioRecordingManager, fileService, @@ -212,6 +217,7 @@ internal class ClaimChatPresenter( private val submitFormUseCase: SubmitFormUseCase, private val submitSelectUseCase: SubmitSelectUseCase, private val submitSummaryUseCase: SubmitSummaryUseCase, + private val submitInformationUseCase: SubmitInformationUseCase, private val skipStepUseCase: SkipStepUseCase, private val audioRecordingManager: AudioRecordingManager, private val fileService: FileService, @@ -721,6 +727,29 @@ internal class ClaimChatPresenter( } } + is ClaimChatEvent.AcknowledgeInformation -> { + currentContinueButtonLoading = true + launch { + submitInformationUseCase + .invoke(event.id) + .fold( + ifLeft = { + currentContinueButtonLoading = false + errorSubmittingStep = it + logcat { "AcknowledgeInformation error: $it" } + }, + ifRight = { claimIntent -> + currentContinueButtonLoading = false + handleNext( + steps, + setOutcome, + claimIntent, + ) { progress = it } + }, + ) + } + } + is ClaimChatEvent.Regret -> { Snapshot.withMutableSnapshot { val stepToUpdate = steps.find { it.id == event.id } ?: return@CollectEvents @@ -1112,6 +1141,7 @@ private fun ClaimIntentStep.clearContent(): ClaimIntentStep = when (val content is StepContent.Task, is StepContent.Deflect, is StepContent.DeflectMessage, + is StepContent.Information, StepContent.Unknown, -> this } 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..fb360dc449 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 @@ -176,6 +176,19 @@ internal sealed interface StepContent { override val isSkippable: Boolean = false } + data class Information( + val notice: String, + val severity: Severity, + val buttonTitle: String, + ) : StepContent { + override val isSkippable: Boolean = false + + enum class Severity { + INFO, + CRITICAL, + } + } + object Unknown : StepContent { override val isSkippable: Boolean = false } 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..4cc162c39d 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 @@ -18,9 +18,11 @@ import octopus.fragment.DeflectionInfoBlockFragment import octopus.fragment.DeflectionMessageFragment import octopus.fragment.FileUploadFragment import octopus.fragment.FormFragment +import octopus.fragment.InformationFragment import octopus.fragment.SummaryFragment import octopus.fragment.TaskFragment import octopus.type.ClaimIntentStepContentFormFieldType +import octopus.type.ClaimIntentStepContentInformationSeverity import octopus.type.ClaimIntentStepContentSelectStyle context(raise: Raise) @@ -188,6 +190,20 @@ private fun ClaimIntentStepContentFragment.toStepContent(locale: CommonLocale): ) } + is InformationFragment -> { + StepContent.Information( + notice = notice, + severity = when (severity) { + ClaimIntentStepContentInformationSeverity.CRITICAL -> StepContent.Information.Severity.CRITICAL + + ClaimIntentStepContentInformationSeverity.INFO, + ClaimIntentStepContentInformationSeverity.UNKNOWN__, + -> StepContent.Information.Severity.INFO + }, + buttonTitle = buttonTitle, + ) + } + else -> { logcat { "ClaimIntentStepContentFragment: Unknown step" } raise(ClaimChatErrorMessage.NeedsUpdate) diff --git a/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/SubmitInformationUseCase.kt b/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/SubmitInformationUseCase.kt new file mode 100644 index 0000000000..af23254318 --- /dev/null +++ b/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/data/SubmitInformationUseCase.kt @@ -0,0 +1,34 @@ +package com.hedvig.feature.claim.chat.data + +import arrow.core.Either +import arrow.core.raise.either +import com.apollographql.apollo.ApolloClient +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.ClaimIntentSubmitInformationMutation + +@SingleIn(AppScope::class) +@Inject +internal class SubmitInformationUseCase( + private val apolloClient: ApolloClient, + private val languageService: LanguageService, +) { + suspend fun invoke(stepId: StepId): Either { + return either { + apolloClient + .mutation(ClaimIntentSubmitInformationMutation(stepId = stepId.value)) + .safeExecute() + .mapLeft { + logcat { "SubmitInformationUseCase error: $it" } + ClaimChatErrorMessage.GeneralError + } + .bind() + .claimIntentSubmitInformation + .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..74ae72e1e0 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 @@ -99,6 +99,7 @@ import com.hedvig.feature.claim.chat.ui.step.ChatClaimSummaryTopContent import com.hedvig.feature.claim.chat.ui.step.ContentSelectStep import com.hedvig.feature.claim.chat.ui.step.DeflectStep import com.hedvig.feature.claim.chat.ui.step.FormStep +import com.hedvig.feature.claim.chat.ui.step.InformationStep import com.hedvig.feature.claim.chat.ui.step.TaskStepBottomContent import com.hedvig.feature.claim.chat.ui.step.TaskStepTopContent import com.hedvig.feature.claim.chat.ui.step.UploadFilesStep @@ -892,6 +893,18 @@ private fun StepBottomContent( ) } + is StepContent.Information -> { + InformationStep( + information = stepItem.stepContent, + isCurrentStep = isCurrentStep, + continueButtonLoading = currentContinueButtonLoading, + onAcknowledge = { + onEvent(ClaimChatEvent.AcknowledgeInformation(stepItem.id)) + }, + modifier = Modifier.fillMaxWidth(), + ) + } + StepContent.Unknown -> { LaunchedEffect(Unit) { logcat(LogPriority.ERROR) { "StepContent.Unknown received in StepBottomContent" } diff --git a/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/ui/step/InformationStep.kt b/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/ui/step/InformationStep.kt new file mode 100644 index 0000000000..5fbd445815 --- /dev/null +++ b/app/feature/feature-claim-chat/src/commonMain/kotlin/com/hedvig/feature/claim/chat/ui/step/InformationStep.kt @@ -0,0 +1,74 @@ +package com.hedvig.feature.claim.chat.ui.step + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import com.hedvig.android.compose.ui.preview.BooleanCollectionPreviewParameterProvider +import com.hedvig.android.design.system.hedvig.HedvigButton +import com.hedvig.android.design.system.hedvig.HedvigNotificationCard +import com.hedvig.android.design.system.hedvig.HedvigPreview +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.NotificationDefaults.NotificationPriority +import com.hedvig.android.design.system.hedvig.Surface +import com.hedvig.feature.claim.chat.data.StepContent + +@Composable +internal fun InformationStep( + information: StepContent.Information, + isCurrentStep: Boolean, + continueButtonLoading: Boolean, + onAcknowledge: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + HedvigNotificationCard( + message = information.notice, + priority = when (information.severity) { + StepContent.Information.Severity.INFO -> NotificationPriority.Info + StepContent.Information.Severity.CRITICAL -> NotificationPriority.Error + }, + ) + if (isCurrentStep) { + HedvigButton( + text = information.buttonTitle, + onClick = onAcknowledge, + isLoading = continueButtonLoading, + enabled = !continueButtonLoading, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +@HedvigPreview +@Composable +private fun PreviewInformationStep( + @PreviewParameter(BooleanCollectionPreviewParameterProvider::class) isCritical: Boolean, +) { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + InformationStep( + information = StepContent.Information( + notice = "Since your home is currently uninhabitable and you have nowhere to stay, please contact us " + + "immediately or seek temporary emergency accommodation.", + severity = if (isCritical) { + StepContent.Information.Severity.CRITICAL + } else { + StepContent.Information.Severity.INFO + }, + buttonTitle = "I understand", + ), + isCurrentStep = true, + continueButtonLoading = false, + onAcknowledge = {}, + ) + } + } +} diff --git a/app/feature/feature-claim-chat/src/commonTest/kotlin/com/hedvig/feature/claim/chat/data/ClaimIntentInformationMappingTest.kt b/app/feature/feature-claim-chat/src/commonTest/kotlin/com/hedvig/feature/claim/chat/data/ClaimIntentInformationMappingTest.kt new file mode 100644 index 0000000000..ab43903154 --- /dev/null +++ b/app/feature/feature-claim-chat/src/commonTest/kotlin/com/hedvig/feature/claim/chat/data/ClaimIntentInformationMappingTest.kt @@ -0,0 +1,61 @@ +package com.hedvig.feature.claim.chat.data + +import arrow.core.raise.either +import assertk.assertThat +import assertk.assertions.isEqualTo +import com.hedvig.android.core.locale.previewCommonLocale +import kotlin.test.Test +import octopus.fragment.ClaimIntentFragment +import octopus.fragment.InformationFragment +import octopus.type.ClaimIntentStepContentInformationSeverity + +class ClaimIntentInformationMappingTest { + @Test + fun `an information step content maps to the information step content`() { + val result = either { + informationClaimIntentFragment(ClaimIntentStepContentInformationSeverity.CRITICAL) + .toClaimIntent(previewCommonLocale) + } + + val stepContent = result.getOrNull()?.next?.step?.claimIntentStep?.stepContent + assertThat(stepContent).isEqualTo( + StepContent.Information( + notice = "Seek emergency accommodation.", + severity = StepContent.Information.Severity.CRITICAL, + buttonTitle = "I understand", + ), + ) + } + + @Test + fun `an unknown information severity degrades to INFO instead of failing the flow`() { + val result = either { + informationClaimIntentFragment(ClaimIntentStepContentInformationSeverity.UNKNOWN__) + .toClaimIntent(previewCommonLocale) + } + + val stepContent = result.getOrNull()?.next?.step?.claimIntentStep?.stepContent + assertThat((stepContent as StepContent.Information).severity) + .isEqualTo(StepContent.Information.Severity.INFO) + } + + private fun informationClaimIntentFragment( + informationSeverity: ClaimIntentStepContentInformationSeverity, + ): ClaimIntentFragment = object : ClaimIntentFragment { + override val id = "intent-id" + override val progress = 0.8 + override val createdClaim = null + override val currentStep = object : ClaimIntentFragment.CurrentStep { + override val id = "step-id" + override val text = null + override val hint = null + override val isRegrettable = false + override val content = object : ClaimIntentFragment.CurrentStep.Content, InformationFragment { + override val __typename = "ClaimIntentStepContentInformation" + override val notice = "Seek emergency accommodation." + override val severity = informationSeverity + override val buttonTitle = "I understand" + } + } + } +} diff --git a/app/feature/feature-claim-chat/src/jvmTest/kotlin/com/hedvig/feature/claim/chat/ui/step/InformationStepRenderTest.kt b/app/feature/feature-claim-chat/src/jvmTest/kotlin/com/hedvig/feature/claim/chat/ui/step/InformationStepRenderTest.kt new file mode 100644 index 0000000000..a6e7eba0da --- /dev/null +++ b/app/feature/feature-claim-chat/src/jvmTest/kotlin/com/hedvig/feature/claim/chat/ui/step/InformationStepRenderTest.kt @@ -0,0 +1,52 @@ +package com.hedvig.feature.claim.chat.ui.step + +import androidx.compose.foundation.layout.padding +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import androidx.compose.ui.use +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.Surface +import com.hedvig.feature.claim.chat.data.StepContent +import java.io.File +import kotlin.test.Test +import org.jetbrains.skia.EncodedImageFormat + +/** + * Renders [InformationStep] headlessly on the desktop target and writes reference PNGs to + * build/renders/. AGP's screenshot-test plugin cannot do this here - it has no support for + * the com.android.kotlin.multiplatform.library target - so this is the module's rendered + * evidence that the step draws correctly. + */ +class InformationStepRenderTest { + @Test + fun `renders the information step for both severities`() { + for (severity in StepContent.Information.Severity.entries) { + val png = ImageComposeScene(width = 1080, height = 720, density = Density(2.75f)).use { scene -> + scene.setContent { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + InformationStep( + information = StepContent.Information( + notice = "Since your home is currently uninhabitable and you have nowhere to stay, please " + + "contact us immediately or seek temporary emergency accommodation.", + severity = severity, + buttonTitle = "I understand", + ), + isCurrentStep = true, + continueButtonLoading = false, + onAcknowledge = {}, + modifier = Modifier.padding(16.dp), + ) + } + } + } + scene.render().encodeToData(EncodedImageFormat.PNG)!!.bytes + } + val file = File("build/renders/InformationStep_${severity.name}.png") + file.parentFile.mkdirs() + file.writeBytes(png) + } + } +}