From 8b31cf776c5875517d946603f39f5ba31e40ab41 Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Thu, 18 Dec 2025 19:08:03 +0100 Subject: [PATCH 1/2] RND-687: Migrate feature-claim-details to MVI with Molecule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert AddFilesViewModel to use MoleculeViewModel and extract presentation logic into AddFilesPresenter following the MVI pattern. Changes include: - AddFilesViewModel now extends MoleculeViewModel - AddFilesPresenter handles state management with Composable functions - AddFilesEvent sealed interface for all user actions - AddFilesDestination updated to emit events instead of direct method calls - Added test dependencies for presenter testing đŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../feature-claim-details/build.gradle.kts | 7 + .../claim/details/ui/AddFilesDestination.kt | 10 +- .../claim/details/ui/AddFilesViewModel.kt | 200 +++++++---- .../claim/details/ui/AddFilesPresenterTest.kt | 330 ++++++++++++++++++ 4 files changed, 472 insertions(+), 75 deletions(-) create mode 100644 app/feature/feature-claim-details/src/test/kotlin/com/hedvig/android/feature/claim/details/ui/AddFilesPresenterTest.kt 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) + } + } +} From 45a38db558e832284fe1c889d64e5bd8d2d6dc95 Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Fri, 19 Dec 2025 15:03:40 +0100 Subject: [PATCH 2/2] Sync schema, strings, and apply code formatting fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update GraphQL schema with latest changes from Octopus - ClaimIntentStep.text is now nullable - Add buttonTitle to ClaimIntentStepContentDeflection - Add PHONE_NUMBER form field type - Add purchaseFlowTagline to Product - Download latest Lokalise strings (EN + SV) - New claim chat UI strings - Updated referral status label terminology - Car claim button titles - Apply Kotlin formatting fixes across chat and claim modules - Handle nullable text field in ClaimIntentExt with orEmpty() đŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../android/apollo/octopus/schema.graphqls | 9 ++++++- .../src/main/res/values-sv-rSE/strings.xml | 27 ++++++++++++++++++- .../src/main/res/values/strings.xml | 27 ++++++++++++++++++- .../data/chat/database/ChatMessageEntity.kt | 4 +-- .../android/data/chat/database/ChatDaoTest.kt | 2 +- .../android/feature/chat/ChatLoadedScreen.kt | 17 ++++++------ .../feature/chat/data/CbmChatRepository.kt | 17 +++++++----- .../feature/chat/model/CbmChatMessage.kt | 24 ++++++++--------- .../feature/claim/chat/ClaimChatViewModel.kt | 2 +- .../feature/claim/chat/data/ClaimIntentExt.kt | 6 ++--- .../TravelCertificateTravellersInput.kt | 7 +++-- 11 files changed, 103 insertions(+), 39 deletions(-) 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-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()