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 503764e9af..7562385179 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
@@ -655,7 +655,7 @@ shapes (see the `ClaimIntentStepContent` union).
"""
type ClaimIntentStep {
id: ID!
- text: String!
+ text: String
content: ClaimIntentStepContent!
isRegrettable: Boolean!
}
@@ -690,6 +690,7 @@ type ClaimIntentStepContentDeflection {
partnersInfo: ClaimIntentStepContentDeflectionInfoBlock
content: ClaimIntentStepContentDeflectionInfoBlock!
faq: [ClaimIntentStepContentDeflectionInfoBlock!]!
+ buttonTitle: String!
}
type ClaimIntentStepContentDeflectionInfoBlock {
title: String!
@@ -774,6 +775,7 @@ enum ClaimIntentStepContentFormFieldType {
TEXT
DATE
NUMBER
+ PHONE_NUMBER
SINGLE_SELECT
MULTI_SELECT
BINARY
@@ -3300,6 +3302,11 @@ type Product {
"""
tagline: String!
"""
+ Localized tagline of the product to be used during purchase flow. This gives editors
+ a couple of tagline options to choose for.
+ """
+ purchaseFlowTagline: String!
+ """
The pillow image asset associated with this product.
"""
pillowImage: StoryblokImageAsset!
diff --git a/app/core/core-resources/src/main/res/values-sv-rSE/strings.xml b/app/core/core-resources/src/main/res/values-sv-rSE/strings.xml
index 8733886c2e..6d0992faee 100644
--- a/app/core/core-resources/src/main/res/values-sv-rSE/strings.xml
+++ b/app/core/core-resources/src/main/res/values-sv-rSE/strings.xml
@@ -184,7 +184,29 @@
Gör röstinspelning
Beskriv i text
Din skadeanmälan
+ Röstinspelning
+ Om du ändrar det här svaret kommer allt du fyllt i efter det att rensas. Du behöver gå igenom de stegen igen.
+ Dubbelkolla att du fyllt i alla obligatoriska fält
+ Uppladdade filer
+ Ladda upp filer
+ Värdet får vara högst %d
+ Värdet måste vara minst %d
+ Fält är obligatoriskt
+ Värdet får vara högst %d tecken
+ Värdet måste vara minst %d tecken
+ Skriven beskrivning
+ Övrig information
+ Vänligen ange telefonnummer ifall vi behöver kontakta dig
+ Se till att vi har rätt telefonnummer ifall vi behöver kontakta dig
+ Inspelning
Överhoppad
+ Överhoppad
+ Skicka in ditt skadeärende
+ Gå till ärende
+ Ditt ärende har skickats in
+ Unknown step
+ Spela in röstmeddelande
+ Beskriv med text
Händelsedatum
Skicka in din skadeanmälan
Vilken försäkring gäller det?
@@ -555,7 +577,7 @@
Rabattkod
Avvaktar...
Hej! Skaffa Hedvig med min tipslänk så får vi båda %1$s rabatt på månadskostnaden. Följ länken: %2$s
- Avbruten
+ Inaktiv
Ja, radera
Spara och fortsätt
Inga träffar på din sökning
@@ -574,6 +596,7 @@
Inställningar
Märke
Fyll i ditt inköpspris
+ Bilskada
Anmäl skada
Questions and answers
Vi vet att det kan vara jobbigt med en skada och vi ska göra allt vi kan för att processen skall gå så snabbt och smidigt som möjligt för dig. \n\nJu mer uppgifter du har möjlighet att ge desto snabbare kan vi hjälpa till. När vi fått alla uppgifterna kommer vi att kontakta dig via mail. \n\nFör det här ärendet kan du inte kontakta oss i appen. Efter att skadan är inskickad kommer all kommunikation ske via mail.
@@ -604,6 +627,7 @@
Vi täcker kostnader som uppstår om du blir akut sjuk, skadar dig eller får akuta tandskador i utlandet. Behöver du akut vård utomlands ska du kontakta Hedvig Global Assistance (SOS International).
Vad din försäkring täcker
+ Glasskada
Vi samarbetar med många olika verkstäder över hela landet. Du kan enkelt boka din reparation online hos någon av våra partners online. Om du hellre vill ringa din verkstad och boka en tid behöver du uppge att du har bilförsäkring hos Hedvig och i vissa fall även ange bolagskoden ”02301”.
Du kan enkelt boka en reparation hos Ryds Bilglas eller Carglass online. De finns på många orter runtom i landet.\n\nMen du kan självklart välja en annan verkstad om det passar dig bättre.
Vi samarbetar med både Ryds Bilglas och Carglass för att du ska kunna få hjälp med din skada så snabbt som möjligt.
@@ -638,6 +662,7 @@
Du kan få hjälp med att bärga bilen om den skadats eller drabbats av ett driftstopp, som till exempel motorstopp eller punktering.
Så länge skadan täcks av försäkringsvillkoren så betalar du endast självrisken på 1750 kr direkt till Assistancekåren.
Du får såklart använda den bärgningstjänst som passar dig bäst. Men förloppet blir oftast snabbare och enklare om du vänder dig direkt till Assistancekåren.
+ Bärgning
Du kontaktar själv Assistancekåren på telefon genom knappen här ovan eller ringer direkt till 010–45 99 222. Självrisken som är på 1750 kr betalar du direkt till Assistancekåren. \n\nOm du skulle vilja använda dig av annan bilbärgning kan du självklart välja den du föredrar. \n\nVid akuta skador och behov av sjukvård ring istället 112.
Vi samarbetar med Assistancekåren för att du ska kunna få hjälp så snabbt som möjligt.
Du behöver kontakta Assistancekåren direkt för att få hjälp med din bärgning
diff --git a/app/core/core-resources/src/main/res/values/strings.xml b/app/core/core-resources/src/main/res/values/strings.xml
index c7b01ab699..87bb192fa0 100644
--- a/app/core/core-resources/src/main/res/values/strings.xml
+++ b/app/core/core-resources/src/main/res/values/strings.xml
@@ -184,7 +184,29 @@
Use voice recording
Describe using text
Your claim
+ Voice recording
+ Changing this answer will reset everything you’ve filled in after it. You’ll need to go through those steps again.
+ Make sure you fill in all the required fields
+ Uploaded files
+ Send files
+ Value must be at most %d
+ Value must be at least %d
+ This field is required
+ Value must be at most %d characters long
+ Value must be at least %d characters long
+ Written description
+ Other information
+ Please provide phone number in the case we need to contact you
+ Make sure we have right phone number in the case we need to contact you
+ Recording
Skipped
+ Skipped
+ Submit your claim
+ Go to claim
+ Your claim was submitted successfully
+ Unknown step
+ Record voice note
+ Describe with text
Date of occurrence
Submit your claim
What insurance is it about?
@@ -555,7 +577,7 @@
Discount code
Pending...
Hey! Get Hedvig using my link and we both get %1$s per month discount on the monthly cost. Follow the link: %2$s
- Cancelled
+ Inactive
Yes, remove
Save and continue
No results for your search
@@ -574,6 +596,7 @@
Settings
Brand
Fill in your purchase price
+ Car claim
Report your claim
Questions and answers
We are committed to making the process as swift and seamless as possible for you. The more information you can provide, the quicker we can assist you. Once we have all the necessary details, we will contact you via email.\n\nPlease note that for this matter, you cannot reach us through the app. After reporting the claim, all communication will take place via email.
@@ -604,6 +627,7 @@
We cover costs due to acute illness, injury and acute dental injury abroad. If you are seriously ill and require emergency medical care, contact Hedvig Global Assistance (SOS International) immediately.
What your insurance covers
+ Glass damage
We collaborate with many different workshops across the country. You can easily book your repair online with one of our online partners. If you would rather call your workshop to book an appointment, you need to state that you have car insurance with Hedvig and in some cases you also need the company code \"02301\".
Get started quickly by contacting a workshop. We have established partnerships with Carglass and Ryds Bilglas.\n\nHowever, you are free to select a different workshop of your preference.
We collaborate with both Ryds Bilglas and Carglass to ensure that you receive assistance with your glass damage as quickly as possible.
@@ -638,6 +662,7 @@
You can get help with towing the car if it has been damaged or experienced a breakdown, such as engine failure or a flat tire.
As long as the damage is covered by the terms, you only pay the deductible of 1750 SEK directly to Assistancekåren.
You are, of course, free to use the towing service that suits you best. However, the process is usually faster and smoother if you contact Assistancekåren directly.
+ Towing
You can contact Assistancekåren by phone using the button above or by calling directly at +46 10–45 99 222. The deductible of 1750 SEK is to be paid directly to Assistancekåren.\n\nIf you prefer to opt for a different towing service, feel free to select the one that suits you best.\n\nIn case of emergency and need for medical assistance, call 112.
We collaborate with Assistancekåren to ensure that you receive assistance as quickly as possible.
You need to contact Assistancekåren directly to receive further assistance
diff --git a/app/data/data-chat/src/main/kotlin/com/hedvig/android/data/chat/database/ChatMessageEntity.kt b/app/data/data-chat/src/main/kotlin/com/hedvig/android/data/chat/database/ChatMessageEntity.kt
index cf731b4a55..76d8304c93 100644
--- a/app/data/data-chat/src/main/kotlin/com/hedvig/android/data/chat/database/ChatMessageEntity.kt
+++ b/app/data/data-chat/src/main/kotlin/com/hedvig/android/data/chat/database/ChatMessageEntity.kt
@@ -26,7 +26,7 @@ data class ChatMessageEntity(
@Embedded
val action: ChatMessageEntityAction?,
@ColumnInfo(defaultValue = "0")
- val isAiGenerationIndicator: Boolean
+ val isAiGenerationIndicator: Boolean,
) {
enum class Sender {
HEDVIG,
@@ -43,7 +43,7 @@ data class ChatMessageEntity(
data class ChatMessageEntityAction(
val actionTitle: String,
- val actionUrl: String
+ val actionUrl: String,
)
data class ChatMessageEntityBanner(
diff --git a/app/data/data-chat/src/test/kotlin/com/hedvig/android/data/chat/database/ChatDaoTest.kt b/app/data/data-chat/src/test/kotlin/com/hedvig/android/data/chat/database/ChatDaoTest.kt
index 24d2d6de8b..44abff7a15 100644
--- a/app/data/data-chat/src/test/kotlin/com/hedvig/android/data/chat/database/ChatDaoTest.kt
+++ b/app/data/data-chat/src/test/kotlin/com/hedvig/android/data/chat/database/ChatDaoTest.kt
@@ -80,7 +80,7 @@ private fun textChatMessageEntity(
isBeingSent = isBeingSent,
banner = null,
action = null,
- isAiGenerationIndicator = false
+ isAiGenerationIndicator = false,
)
@Database(
diff --git a/app/feature/feature-chat/src/main/kotlin/com/hedvig/android/feature/chat/ChatLoadedScreen.kt b/app/feature/feature-chat/src/main/kotlin/com/hedvig/android/feature/chat/ChatLoadedScreen.kt
index 5b1550f053..39118b0f43 100644
--- a/app/feature/feature-chat/src/main/kotlin/com/hedvig/android/feature/chat/ChatLoadedScreen.kt
+++ b/app/feature/feature-chat/src/main/kotlin/com/hedvig/android/feature/chat/ChatLoadedScreen.kt
@@ -422,8 +422,8 @@ private fun ChatLazyColumn(
val defaultWidth = 0.8f
var dynamicBubbleWidthFraction by remember { mutableFloatStateOf(defaultWidth) }
val isLastMessage = index == 0
- val isThisIndicator = uiChatMessage?.chatMessage is CbmChatMessage.ChatMessageText
- && uiChatMessage.chatMessage.isAiGenerationIndicator
+ val isThisIndicator = uiChatMessage?.chatMessage is CbmChatMessage.ChatMessageText &&
+ uiChatMessage.chatMessage.isAiGenerationIndicator
if (isThisIndicator && isLastMessage) {
AiResponseBeingGeneratedIndicator(uiChatMessage.chatMessage)
} else {
@@ -549,7 +549,6 @@ private fun ChatBubble(
}
is CbmChatMessage.ChatMessageText -> {
-
Surface(
shape = HedvigTheme.shapes.cornerLarge,
color = chatMessage.backgroundColor(),
@@ -645,7 +644,7 @@ private fun ChatBubble(
}
ChatMessageFile.MimeType.OTHER,
- -> {
+ -> {
AttachedFileMessage(
onClick = { openUrl(chatMessage.url) },
modifier = Modifier.semantics {
@@ -735,9 +734,7 @@ private fun ChatBubble(
}
@Composable
-private fun AiResponseBeingGeneratedIndicator(
- chatMessage: CbmChatMessage,
-) {
+private fun AiResponseBeingGeneratedIndicator(chatMessage: CbmChatMessage) {
Surface(
shape = HedvigTheme.shapes.cornerLarge,
color = chatMessage.backgroundColor(),
@@ -1240,7 +1237,11 @@ private fun PreviewChatLoadedScreen() {
ChatMessagePhoto("5", Instant.parse("2024-05-01T00:01:00Z"), Uri.EMPTY),
ChatMessageText("6", Instant.parse("2024-05-01T00:02:00Z"), "Failed message"),
CbmChatMessage.ChatMessageText(
- "7", HEDVIG, Instant.parse("2024-05-01T00:03:00Z"), null, "Last message",
+ "7",
+ HEDVIG,
+ Instant.parse("2024-05-01T00:03:00Z"),
+ null,
+ "Last message",
action = CbmChatMessage.ChatMessageTextAction("go somewhere", ""),
),
)
diff --git a/app/feature/feature-chat/src/main/kotlin/com/hedvig/android/feature/chat/data/CbmChatRepository.kt b/app/feature/feature-chat/src/main/kotlin/com/hedvig/android/feature/chat/data/CbmChatRepository.kt
index ddb4d3f809..5249027b4c 100644
--- a/app/feature/feature-chat/src/main/kotlin/com/hedvig/android/feature/chat/data/CbmChatRepository.kt
+++ b/app/feature/feature-chat/src/main/kotlin/com/hedvig/android/feature/chat/data/CbmChatRepository.kt
@@ -357,9 +357,12 @@ internal class CbmChatRepositoryImpl(
"Empty message page for conversation $conversationId"
}
val messages = messagePage.messages.mapNotNull { it.toChatMessage() }
- val messagesWithIndicator = if (isBeingGenerated) messages +
- CbmChatMessage.aiGeneratingIndicator(Clock.System.now())
- else messages
+ val messagesWithIndicator = if (isBeingGenerated) {
+ messages +
+ CbmChatMessage.aiGeneratingIndicator(Clock.System.now())
+ } else {
+ messages
+ }
ChatMessagePageResponse(
messages = messagesWithIndicator,
newerToken = messagePage.newerToken,
@@ -538,9 +541,9 @@ private fun ChatMessageFragment.toChatMessage(): CbmChatMessage? = when (this) {
action = actions?.let { action ->
CbmChatMessage.ChatMessageTextAction(
title = action.title,
- url = action.url
+ url = action.url,
)
- }
+ },
)
}
}
@@ -588,7 +591,7 @@ private fun ConversationInput.toChatMessageEntity(
isBeingSent = true,
banner = null,
action = null,
- isAiGenerationIndicator = false
+ isAiGenerationIndicator = false,
)
}
@@ -606,7 +609,7 @@ private fun ConversationInput.toChatMessageEntity(
isBeingSent = true,
banner = null,
action = null,
- isAiGenerationIndicator = false
+ isAiGenerationIndicator = false,
)
}
}
diff --git a/app/feature/feature-chat/src/main/kotlin/com/hedvig/android/feature/chat/model/CbmChatMessage.kt b/app/feature/feature-chat/src/main/kotlin/com/hedvig/android/feature/chat/model/CbmChatMessage.kt
index 55a357a5d3..ed121eea9e 100644
--- a/app/feature/feature-chat/src/main/kotlin/com/hedvig/android/feature/chat/model/CbmChatMessage.kt
+++ b/app/feature/feature-chat/src/main/kotlin/com/hedvig/android/feature/chat/model/CbmChatMessage.kt
@@ -28,7 +28,7 @@ internal sealed interface CbmChatMessage {
override val banner: Banner?,
val text: String,
val action: ChatMessageTextAction?,
- val isAiGenerationIndicator: Boolean = false
+ val isAiGenerationIndicator: Boolean = false,
) : CbmChatMessage
data class ChatMessageGif(
@@ -57,7 +57,7 @@ internal sealed interface CbmChatMessage {
data class ChatMessageTextAction(
val title: String,
- val url: String
+ val url: String,
)
companion object {
@@ -68,7 +68,7 @@ internal sealed interface CbmChatMessage {
banner = null,
text = "",
action = null,
- isAiGenerationIndicator = true
+ isAiGenerationIndicator = true,
)
}
@@ -165,7 +165,7 @@ internal fun CbmChatMessage.toChatMessageEntity(conversationId: Uuid): ChatMessa
isBeingSent = false,
banner = banner.toBannerEntity(),
action = null,
- isAiGenerationIndicator = false
+ isAiGenerationIndicator = false,
)
is CbmChatMessage.ChatMessageGif -> ChatMessageEntity(
@@ -181,7 +181,7 @@ internal fun CbmChatMessage.toChatMessageEntity(conversationId: Uuid): ChatMessa
isBeingSent = false,
banner = banner.toBannerEntity(),
action = null,
- isAiGenerationIndicator = false
+ isAiGenerationIndicator = false,
)
is CbmChatMessage.ChatMessageText -> ChatMessageEntity(
@@ -199,10 +199,10 @@ internal fun CbmChatMessage.toChatMessageEntity(conversationId: Uuid): ChatMessa
action = action?.let {
ChatMessageEntityAction(
actionTitle = it.title,
- actionUrl = it.url
+ actionUrl = it.url,
)
},
- isAiGenerationIndicator = isAiGenerationIndicator
+ isAiGenerationIndicator = isAiGenerationIndicator,
)
is CbmChatMessage.FailedToBeSent.ChatMessageText -> ChatMessageEntity(
@@ -218,7 +218,7 @@ internal fun CbmChatMessage.toChatMessageEntity(conversationId: Uuid): ChatMessa
isBeingSent = false,
banner = banner.toBannerEntity(),
action = null,
- isAiGenerationIndicator = false
+ isAiGenerationIndicator = false,
)
is CbmChatMessage.FailedToBeSent.ChatMessagePhoto -> ChatMessageEntity(
@@ -234,7 +234,7 @@ internal fun CbmChatMessage.toChatMessageEntity(conversationId: Uuid): ChatMessa
isBeingSent = false,
banner = banner.toBannerEntity(),
action = null,
- isAiGenerationIndicator = false
+ isAiGenerationIndicator = false,
)
is CbmChatMessage.FailedToBeSent.ChatMessageMedia -> ChatMessageEntity(
@@ -250,7 +250,7 @@ internal fun CbmChatMessage.toChatMessageEntity(conversationId: Uuid): ChatMessa
isBeingSent = false,
banner = banner.toBannerEntity(),
action = null,
- isAiGenerationIndicator = false
+ isAiGenerationIndicator = false,
)
}
}
@@ -290,10 +290,10 @@ internal fun ChatMessageEntity.toChatMessage(): CbmChatMessage? {
action = action?.let { entityAction ->
CbmChatMessage.ChatMessageTextAction(
title = entityAction.actionTitle,
- url = entityAction.actionUrl
+ url = entityAction.actionUrl,
)
},
- isAiGenerationIndicator = isAiGenerationIndicator
+ isAiGenerationIndicator = isAiGenerationIndicator,
)
gifUrl != null -> CbmChatMessage.ChatMessageGif(id.toString(), sender, sentAt, banner.toBanner(), gifUrl!!)
url != null && mimeType != null -> {
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 d95c592830..4fea5b82d6 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
@@ -21,7 +21,7 @@ import com.hedvig.feature.claim.chat.data.ClaimIntentOutcome
import com.hedvig.feature.claim.chat.data.ClaimIntentStep
import com.hedvig.feature.claim.chat.data.FieldId
import com.hedvig.feature.claim.chat.data.FormSubmissionData
-import com.hedvig.feature.claim.chat.data.FormSubmissionData.*
+import com.hedvig.feature.claim.chat.data.FormSubmissionData.Field
import com.hedvig.feature.claim.chat.data.GetClaimIntentUseCase
import com.hedvig.feature.claim.chat.data.StartClaimIntentUseCase
import com.hedvig.feature.claim.chat.data.StepContent
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 87f16d7dc9..b62594402d 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
@@ -39,7 +39,7 @@ internal fun ClaimIntentFragment.toClaimIntent(): ClaimIntent {
private fun ClaimIntentFragment.CurrentStep.toClaimIntentStep(): ClaimIntentStep {
return ClaimIntentStep(
id = StepId(id),
- text = text,
+ text = text.orEmpty(),
stepContent = this.content.toStepContent(),
)
}
@@ -47,10 +47,10 @@ private fun ClaimIntentFragment.CurrentStep.toClaimIntentStep(): ClaimIntentStep
private fun ClaimIntentStepContentFragment.toStepContent(): StepContent {
return when (this) {
is FormFragment -> StepContent.Form(this.fields.toFields(), isSkippable)
- is ContentSelectFragment -> StepContent.ContentSelect(options.toOptions(), isSkippable )
+ is ContentSelectFragment -> StepContent.ContentSelect(options.toOptions(), isSkippable)
is TaskFragment -> StepContent.Task(listOf(description), isCompleted)
is AudioRecordingFragment -> StepContent.AudioRecording(hint, uploadUri, isSkippable)
- is FileUploadFragment -> StepContent.FileUpload(uploadUri, isSkippable )
+ is FileUploadFragment -> StepContent.FileUpload(uploadUri, isSkippable)
is SummaryFragment -> StepContent.Summary(
items = items.map { StepContent.Summary.Item(it.title, it.value) },
audioRecordings = audioRecordings.map { StepContent.Summary.AudioRecording(it.url) },
diff --git a/app/feature/feature-claim-details/build.gradle.kts b/app/feature/feature-claim-details/build.gradle.kts
index 084a36894d..0bd8cc1b22 100644
--- a/app/feature/feature-claim-details/build.gradle.kts
+++ b/app/feature/feature-claim-details/build.gradle.kts
@@ -53,4 +53,11 @@ dependencies {
implementation(projects.navigationCommon)
implementation(projects.navigationCompose)
implementation(projects.navigationCore)
+
+ testImplementation(libs.assertK)
+ testImplementation(libs.coroutines.test)
+ testImplementation(libs.junit)
+ testImplementation(libs.turbine)
+ testImplementation(projects.loggingTest)
+ testImplementation(projects.moleculeTest)
}
diff --git a/app/feature/feature-claim-details/src/main/kotlin/com/hedvig/android/feature/claim/details/ui/AddFilesDestination.kt b/app/feature/feature-claim-details/src/main/kotlin/com/hedvig/android/feature/claim/details/ui/AddFilesDestination.kt
index bdf2065fe9..1cb9a4e76a 100644
--- a/app/feature/feature-claim-details/src/main/kotlin/com/hedvig/android/feature/claim/details/ui/AddFilesDestination.kt
+++ b/app/feature/feature-claim-details/src/main/kotlin/com/hedvig/android/feature/claim/details/ui/AddFilesDestination.kt
@@ -61,7 +61,9 @@ internal fun AddFilesDestination(
}
}
- val addLocalFile = viewModel::addLocalFile
+ val addLocalFile: (Uri) -> Unit = { uri ->
+ viewModel.emit(AddFilesEvent.AddLocalFile(uri.toString()))
+ }
val photoCaptureState = rememberPhotoCaptureState(appPackageId = appPackageId) { uri ->
logcat { "ChatFileState sending uri:$uri" }
addLocalFile(uri)
@@ -85,9 +87,9 @@ internal fun AddFilesDestination(
uiState = uiState,
navigateUp = navigateUp,
imageLoader = imageLoader,
- onContinue = viewModel::uploadFiles,
- onRemove = viewModel::onRemoveFile,
- onDismissError = viewModel::dismissError,
+ onContinue = { viewModel.emit(AddFilesEvent.UploadFiles) },
+ onRemove = { viewModel.emit(AddFilesEvent.RemoveFile(it)) },
+ onDismissError = { viewModel.emit(AddFilesEvent.DismissError) },
launchTakePhotoRequest = photoCaptureState::launchTakePhotoRequest,
onPickPhoto = { photoPicker.launch(PickVisualMediaRequest()) },
onPickFile = { filePicker.launch("*/*") },
diff --git a/app/feature/feature-claim-details/src/main/kotlin/com/hedvig/android/feature/claim/details/ui/AddFilesViewModel.kt b/app/feature/feature-claim-details/src/main/kotlin/com/hedvig/android/feature/claim/details/ui/AddFilesViewModel.kt
index 244199eb6f..5504a6ccf0 100644
--- a/app/feature/feature-claim-details/src/main/kotlin/com/hedvig/android/feature/claim/details/ui/AddFilesViewModel.kt
+++ b/app/feature/feature-claim-details/src/main/kotlin/com/hedvig/android/feature/claim/details/ui/AddFilesViewModel.kt
@@ -1,97 +1,155 @@
package com.hedvig.android.feature.claim.details.ui
import android.net.Uri
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.viewModelScope
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import arrow.core.Either
import arrow.core.raise.either
import com.hedvig.android.apollo.NetworkCacheManager
+import com.hedvig.android.core.common.ErrorMessage
import com.hedvig.android.core.fileupload.FileService
import com.hedvig.android.core.fileupload.UploadFileUseCase
+import com.hedvig.android.core.fileupload.UploadSuccess
import com.hedvig.android.core.uidata.UiFile
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.update
+import com.hedvig.android.molecule.public.MoleculePresenter
+import com.hedvig.android.molecule.public.MoleculePresenterScope
+import com.hedvig.android.molecule.public.MoleculeViewModel
import kotlinx.coroutines.launch
internal class AddFilesViewModel(
- private val uploadFileUseCase: UploadFileUseCase,
- private val fileService: FileService,
- private val targetUploadUrl: String,
- private val cacheManager: NetworkCacheManager,
+ uploadFileUseCase: UploadFileUseCase,
+ fileService: FileService,
+ targetUploadUrl: String,
+ cacheManager: NetworkCacheManager,
initialFilesUri: List,
-) : ViewModel() {
- private val _uiState = MutableStateFlow(FileUploadUiState())
- val uiState: StateFlow = _uiState.asStateFlow()
+) : MoleculeViewModel(
+ initialState = FileUploadUiState(),
+ presenter = AddFilesPresenter(
+ uploadFiles = { url, uriStrings ->
+ uploadFileUseCase.invoke(url, uriStrings.map { Uri.parse(it) })
+ },
+ getMimeType = { uriString -> fileService.getMimeType(Uri.parse(uriString)) },
+ getFileName = { uriString -> fileService.getFileName(Uri.parse(uriString)) },
+ targetUploadUrl = targetUploadUrl,
+ clearCache = cacheManager::clearCache,
+ initialFilesUri = initialFilesUri,
+ ),
+ )
+
+internal class AddFilesPresenter(
+ private val uploadFiles: suspend (url: String, uriStrings: List) -> Either,
+ private val getMimeType: (String) -> String?,
+ private val getFileName: (String) -> String?,
+ private val targetUploadUrl: String,
+ private val clearCache: suspend () -> Unit,
+ private val initialFilesUri: List,
+) : MoleculePresenter {
+ @Composable
+ override fun MoleculePresenterScope.present(lastState: FileUploadUiState): FileUploadUiState {
+ var uiState by remember { mutableStateOf(lastState) }
- init {
- try {
- for (uri in initialFilesUri) {
- addLocalFile(Uri.parse(uri))
+ // Process initial files on first launch
+ LaunchedEffect(Unit) {
+ // State preservation - already have files loaded
+ if (lastState.localFiles.isNotEmpty()) {
+ return@LaunchedEffect
+ }
+ try {
+ for (uriString in initialFilesUri) {
+ if (uriString in uiState.localFiles.map { it.id }) {
+ continue
+ }
+ val mimeType = getMimeType(uriString) ?: ""
+ val name = getFileName(uriString) ?: uriString
+ val localFile = UiFile(
+ name = name,
+ localPath = uriString,
+ mimeType = mimeType,
+ id = uriString,
+ url = null,
+ )
+ uiState = uiState.copy(localFiles = uiState.localFiles + localFile)
+ }
+ } catch (e: Exception) {
+ uiState = uiState.copy(errorMessage = e.message)
}
- } catch (e: Exception) {
- _uiState.update { it.copy(errorMessage = e.message) }
}
- }
- fun uploadFiles() {
- viewModelScope.launch {
- _uiState.update { it.copy(isLoading = true) }
- either {
- val uris = uiState.value.localFiles.map { Uri.parse(it.localPath) }
- if (uris.isNotEmpty()) {
- val result = uploadFileUseCase.invoke(url = targetUploadUrl, uris = uris).bind()
- result.fileIds
- } else {
- emptyList()
+ CollectEvents { event ->
+ when (event) {
+ is AddFilesEvent.AddLocalFile -> {
+ val uriString = event.uriString
+ if (uriString in uiState.localFiles.map { it.id }) {
+ return@CollectEvents
+ }
+ try {
+ val mimeType = getMimeType(uriString) ?: ""
+ val name = getFileName(uriString) ?: uriString
+ val localFile = UiFile(
+ name = name,
+ localPath = uriString,
+ mimeType = mimeType,
+ id = uriString,
+ url = null,
+ )
+ uiState = uiState.copy(localFiles = uiState.localFiles + localFile)
+ } catch (e: Exception) {
+ uiState = uiState.copy(errorMessage = e.message)
+ }
}
- }.fold(
- ifRight = { uploadedFileIds ->
- cacheManager.clearCache()
- _uiState.update { it.copy(uploadedFileIds = uploadedFileIds, isLoading = false) }
- },
- ifLeft = { errorMessage ->
- _uiState.update { it.copy(errorMessage = errorMessage.message, isLoading = false) }
- },
- )
- }
- }
- fun addLocalFile(uri: Uri) {
- if (uri.toString() in _uiState.value.localFiles.map { it.id }) {
- return
- }
- _uiState.update {
- try {
- val mimeType = fileService.getMimeType(uri)
- val name = fileService.getFileName(uri) ?: uri.toString()
- val localFile = UiFile(
- name = name,
- localPath = uri.toString(),
- mimeType = mimeType,
- id = uri.toString(),
- url = null,
- )
- it.copy(localFiles = it.localFiles + localFile)
- } catch (e: Exception) {
- it.copy(errorMessage = e.message)
+ AddFilesEvent.UploadFiles -> {
+ if (uiState.isLoading) return@CollectEvents
+ uiState = uiState.copy(isLoading = true)
+ launch {
+ either {
+ val uriStrings = uiState.localFiles.mapNotNull { it.localPath }
+ if (uriStrings.isNotEmpty()) {
+ val result = uploadFiles(targetUploadUrl, uriStrings).bind()
+ result.fileIds
+ } else {
+ emptyList()
+ }
+ }.fold(
+ ifRight = { uploadedFileIds ->
+ clearCache()
+ uiState = uiState.copy(uploadedFileIds = uploadedFileIds, isLoading = false)
+ },
+ ifLeft = { errorMessage ->
+ uiState = uiState.copy(errorMessage = errorMessage.message, isLoading = false)
+ },
+ )
+ }
+ }
+
+ AddFilesEvent.DismissError -> {
+ uiState = uiState.copy(errorMessage = null)
+ }
+
+ is AddFilesEvent.RemoveFile -> {
+ uiState = uiState.copy(
+ localFiles = uiState.localFiles.filterNot { it.id == event.fileId },
+ )
+ }
}
}
- }
- fun dismissError() {
- _uiState.update {
- it.copy(errorMessage = null)
- }
+ return uiState
}
+}
- fun onRemoveFile(fileId: String) {
- _uiState.update {
- it.copy(
- localFiles = it.localFiles.filterNot { it.id == fileId },
- )
- }
- }
+internal sealed interface AddFilesEvent {
+ data class AddLocalFile(val uriString: String) : AddFilesEvent
+
+ data object UploadFiles : AddFilesEvent
+
+ data object DismissError : AddFilesEvent
+
+ data class RemoveFile(val fileId: String) : AddFilesEvent
}
internal data class FileUploadUiState(
diff --git a/app/feature/feature-claim-details/src/test/kotlin/com/hedvig/android/feature/claim/details/ui/AddFilesPresenterTest.kt b/app/feature/feature-claim-details/src/test/kotlin/com/hedvig/android/feature/claim/details/ui/AddFilesPresenterTest.kt
new file mode 100644
index 0000000000..7f21a6fd5c
--- /dev/null
+++ b/app/feature/feature-claim-details/src/test/kotlin/com/hedvig/android/feature/claim/details/ui/AddFilesPresenterTest.kt
@@ -0,0 +1,330 @@
+package com.hedvig.android.feature.claim.details.ui
+
+import arrow.core.Either
+import arrow.core.left
+import arrow.core.right
+import assertk.assertThat
+import assertk.assertions.containsExactly
+import assertk.assertions.isEmpty
+import assertk.assertions.isEqualTo
+import assertk.assertions.isFalse
+import assertk.assertions.isNull
+import assertk.assertions.isTrue
+import com.hedvig.android.core.common.ErrorMessage
+import com.hedvig.android.core.fileupload.UploadSuccess
+import com.hedvig.android.core.uidata.UiFile
+import com.hedvig.android.logger.TestLogcatLoggingRule
+import com.hedvig.android.molecule.test.test
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+
+class AddFilesPresenterTest {
+ @get:Rule
+ val testLogcatLogger = TestLogcatLoggingRule()
+
+ private val targetUploadUrl = "https://example.com/upload?claimId=123"
+
+ private fun createPresenter(
+ uploadFiles: suspend (url: String, uriStrings: List) -> Either = { _, _ ->
+ UploadSuccess(listOf("file-id-1")).right()
+ },
+ getMimeType: (String) -> String? = { "image/jpeg" },
+ getFileName: (String) -> String? = { "test-file.jpg" },
+ clearCache: suspend () -> Unit = {},
+ initialFilesUri: List = emptyList(),
+ ) = AddFilesPresenter(
+ uploadFiles = uploadFiles,
+ getMimeType = getMimeType,
+ getFileName = getFileName,
+ targetUploadUrl = targetUploadUrl,
+ clearCache = clearCache,
+ initialFilesUri = initialFilesUri,
+ )
+
+ @Test
+ fun `initial state has empty local files`() = runTest {
+ val presenter = createPresenter()
+
+ presenter.test(FileUploadUiState()) {
+ val initialState = awaitItem()
+ assertThat(initialState.localFiles).isEmpty()
+ assertThat(initialState.isLoading).isFalse()
+ assertThat(initialState.errorMessage).isNull()
+ assertThat(initialState.uploadedFileIds).isEmpty()
+ }
+ }
+
+ @Test
+ fun `initial files are loaded on startup`() = runTest {
+ val initialUri = "content://test/file1"
+ val presenter = createPresenter(
+ initialFilesUri = listOf(initialUri),
+ getFileName = { "initial-file.jpg" },
+ getMimeType = { "image/jpeg" },
+ )
+
+ presenter.test(FileUploadUiState()) {
+ // First emission might be before LaunchedEffect completes, skip to final state
+ skipItems(1)
+ val state = awaitItem()
+ assertThat(state.localFiles).containsExactly(
+ UiFile(
+ name = "initial-file.jpg",
+ localPath = initialUri,
+ mimeType = "image/jpeg",
+ id = initialUri,
+ url = null,
+ ),
+ )
+ }
+ }
+
+ @Test
+ fun `adding a local file appends to the list`() = runTest {
+ val presenter = createPresenter()
+ val testUri = "content://test/new-file"
+
+ presenter.test(FileUploadUiState()) {
+ awaitItem()
+
+ sendEvent(AddFilesEvent.AddLocalFile(testUri))
+
+ val state = awaitItem()
+ assertThat(state.localFiles.size).isEqualTo(1)
+ assertThat(state.localFiles[0].id).isEqualTo(testUri)
+ assertThat(state.localFiles[0].name).isEqualTo("test-file.jpg")
+ assertThat(state.localFiles[0].mimeType).isEqualTo("image/jpeg")
+ }
+ }
+
+ @Test
+ fun `adding duplicate file is ignored`() = runTest {
+ val presenter = createPresenter()
+ val testUri = "content://test/file"
+
+ presenter.test(FileUploadUiState()) {
+ awaitItem()
+
+ sendEvent(AddFilesEvent.AddLocalFile(testUri))
+ val stateWithOneFile = awaitItem()
+ assertThat(stateWithOneFile.localFiles.size).isEqualTo(1)
+
+ sendEvent(AddFilesEvent.AddLocalFile(testUri))
+ // Should not emit a new state since duplicate was ignored
+ expectNoEvents()
+ }
+ }
+
+ @Test
+ fun `adding file with exception shows error`() = runTest {
+ val presenter = createPresenter(
+ getMimeType = { throw RuntimeException("Failed to get mime type") },
+ )
+ val testUri = "content://test/file"
+
+ presenter.test(FileUploadUiState()) {
+ awaitItem()
+
+ sendEvent(AddFilesEvent.AddLocalFile(testUri))
+
+ val state = awaitItem()
+ assertThat(state.errorMessage).isEqualTo("Failed to get mime type")
+ assertThat(state.localFiles).isEmpty()
+ }
+ }
+
+ @Test
+ fun `removing a file removes it from the list`() = runTest {
+ val existingFile = UiFile(
+ name = "existing.jpg",
+ localPath = "content://test/existing",
+ mimeType = "image/jpeg",
+ id = "file-1",
+ url = null,
+ )
+ val presenter = createPresenter()
+ val initialState = FileUploadUiState(localFiles = listOf(existingFile))
+
+ presenter.test(initialState) {
+ awaitItem()
+
+ sendEvent(AddFilesEvent.RemoveFile("file-1"))
+
+ val state = awaitItem()
+ assertThat(state.localFiles).isEmpty()
+ }
+ }
+
+ @Test
+ fun `removing non-existent file keeps list unchanged`() = runTest {
+ val existingFile = UiFile(
+ name = "existing.jpg",
+ localPath = "content://test/existing",
+ mimeType = "image/jpeg",
+ id = "file-1",
+ url = null,
+ )
+ val presenter = createPresenter()
+ val initialState = FileUploadUiState(localFiles = listOf(existingFile))
+
+ presenter.test(initialState) {
+ val initial = awaitItem()
+ assertThat(initial.localFiles.size).isEqualTo(1)
+
+ sendEvent(AddFilesEvent.RemoveFile("non-existent-id"))
+
+ // filterNot on non-existent id produces same list, but state is still emitted
+ // Actually the state emission happens with same value, which awaitItem() filters
+ expectNoEvents()
+ }
+ }
+
+ @Test
+ fun `upload files successfully`() = runTest {
+ var cacheClearCalled = false
+ val presenter = createPresenter(
+ uploadFiles = { _, _ -> UploadSuccess(listOf("uploaded-id-1", "uploaded-id-2")).right() },
+ clearCache = { cacheClearCalled = true },
+ )
+ val existingFile = UiFile(
+ name = "file.jpg",
+ localPath = "content://test/file",
+ mimeType = "image/jpeg",
+ id = "file-1",
+ url = null,
+ )
+ val initialState = FileUploadUiState(localFiles = listOf(existingFile))
+
+ presenter.test(initialState) {
+ awaitItem()
+
+ sendEvent(AddFilesEvent.UploadFiles)
+
+ // Skip to final state - loading and success might be emitted quickly
+ val finalState = awaitItem()
+ // The final state should have the uploaded file ids
+ assertThat(finalState.uploadedFileIds).containsExactly("uploaded-id-1", "uploaded-id-2")
+ assertThat(finalState.isLoading).isFalse()
+ assertThat(cacheClearCalled).isTrue()
+ }
+ }
+
+ @Test
+ fun `upload files with error shows error message`() = runTest {
+ val presenter = createPresenter(
+ uploadFiles = { _, _ -> ErrorMessage("Upload failed").left() },
+ )
+ val existingFile = UiFile(
+ name = "file.jpg",
+ localPath = "content://test/file",
+ mimeType = "image/jpeg",
+ id = "file-1",
+ url = null,
+ )
+ val initialState = FileUploadUiState(localFiles = listOf(existingFile))
+
+ presenter.test(initialState) {
+ awaitItem()
+
+ sendEvent(AddFilesEvent.UploadFiles)
+
+ // Skip to final state
+ val errorState = awaitItem()
+ assertThat(errorState.isLoading).isFalse()
+ assertThat(errorState.errorMessage).isEqualTo("Upload failed")
+ assertThat(errorState.uploadedFileIds).isEmpty()
+ }
+ }
+
+ @Test
+ fun `dismiss error clears error message`() = runTest {
+ val presenter = createPresenter()
+ val initialState = FileUploadUiState(errorMessage = "Some error")
+
+ presenter.test(initialState) {
+ awaitItem()
+
+ sendEvent(AddFilesEvent.DismissError)
+
+ val state = awaitItem()
+ assertThat(state.errorMessage).isNull()
+ }
+ }
+
+ @Test
+ fun `upload while loading is ignored`() = runTest {
+ val presenter = createPresenter()
+ val initialState = FileUploadUiState(isLoading = true)
+
+ presenter.test(initialState) {
+ awaitItem()
+
+ sendEvent(AddFilesEvent.UploadFiles)
+
+ // Should not emit new states since already loading
+ expectNoEvents()
+ }
+ }
+
+ @Test
+ fun `state is preserved on back navigation`() = runTest {
+ val existingFile = UiFile(
+ name = "preserved.jpg",
+ localPath = "content://test/preserved",
+ mimeType = "image/jpeg",
+ id = "preserved-id",
+ url = null,
+ )
+ val preservedState = FileUploadUiState(
+ localFiles = listOf(existingFile),
+ isLoading = false,
+ )
+ val presenter = createPresenter(initialFilesUri = emptyList())
+
+ presenter.test(preservedState) {
+ val state = awaitItem()
+ // Should preserve the existing files without clearing them
+ assertThat(state.localFiles.size).isEqualTo(1)
+ assertThat(state.localFiles[0].id).isEqualTo("preserved-id")
+ }
+ }
+
+ @Test
+ fun `file without name uses uri as name`() = runTest {
+ val presenter = createPresenter(
+ getFileName = { null },
+ )
+ val testUri = "content://test/unnamed-file"
+
+ presenter.test(FileUploadUiState()) {
+ awaitItem()
+
+ sendEvent(AddFilesEvent.AddLocalFile(testUri))
+
+ val state = awaitItem()
+ assertThat(state.localFiles[0].name).isEqualTo("content://test/unnamed-file")
+ }
+ }
+
+ @Test
+ fun `multiple files can be added sequentially`() = runTest {
+ val presenter = createPresenter()
+
+ presenter.test(FileUploadUiState()) {
+ awaitItem()
+
+ sendEvent(AddFilesEvent.AddLocalFile("content://test/file1"))
+ val stateWithOne = awaitItem()
+ assertThat(stateWithOne.localFiles.size).isEqualTo(1)
+
+ sendEvent(AddFilesEvent.AddLocalFile("content://test/file2"))
+ val stateWithTwo = awaitItem()
+ assertThat(stateWithTwo.localFiles.size).isEqualTo(2)
+
+ sendEvent(AddFilesEvent.AddLocalFile("content://test/file3"))
+ val stateWithThree = awaitItem()
+ assertThat(stateWithThree.localFiles.size).isEqualTo(3)
+ }
+ }
+}
diff --git a/app/feature/feature-travel-certificate/src/main/kotlin/com/hedvig/android/feature/travelcertificate/ui/generatewho/TravelCertificateTravellersInput.kt b/app/feature/feature-travel-certificate/src/main/kotlin/com/hedvig/android/feature/travelcertificate/ui/generatewho/TravelCertificateTravellersInput.kt
index 612a74b225..9e2cdb287d 100644
--- a/app/feature/feature-travel-certificate/src/main/kotlin/com/hedvig/android/feature/travelcertificate/ui/generatewho/TravelCertificateTravellersInput.kt
+++ b/app/feature/feature-travel-certificate/src/main/kotlin/com/hedvig/android/feature/travelcertificate/ui/generatewho/TravelCertificateTravellersInput.kt
@@ -116,8 +116,11 @@ private fun TravelCertificateTravellersInput(
},
selectedOptions = uiState.coInsuredList.filter { it.isIncluded }.map { RadioOptionId(it.id.value) },
onRadioOptionSelected = { radioOptionId ->
- changeCoInsuredChecked(uiState.coInsuredList.first {
- radioOptionId.id == it.id.value })
+ changeCoInsuredChecked(
+ uiState.coInsuredList.first {
+ radioOptionId.id == it.id.value
+ },
+ )
},
modifier = Modifier
.fillMaxWidth()