From 2c77786c56d2cb4b8aa247aeadb7ed157fc0b233 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Sun, 7 Jun 2026 19:11:02 +0300 Subject: [PATCH 01/12] Drive presenter navigation directly via injected Backstack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, any navigation originating from a presenter had to go through a flag-dance: the presenter set a Boolean/nullable flag in UiState, the UI observed it in a LaunchedEffect, navigated, then fired an event back to reset the flag so recomposition/config-change wouldn't re-trigger it. This existed only because presenters had no way to navigate. Now that Backstack is an app-scoped injectable, presenters navigate directly: the flag field, its reset event, and the UI observer are removed across the board. This clarifies what triggers navigation — it now lives at the point of the decision (event handler / result fold) instead of being smeared across UiState, a LaunchedEffect, and an event round-trip. Affected feature modules: movingflow, choose-tier, addon-purchase, terminate-insurance, edit-coinsured, remove-addons, payout-account, travel-certificate, insurance-certificate, help-center, payments, chip-id. Navigation-sensitive cases preserved exactly: - edit-coinsured: triage and edit presenters are each shared between two entries, so the pop anchor is resolved at runtime via findLastOrNull<>() (navigateFromTriage / navigateToEditCoInsuredSuccess). - movingflow: SelectContract keeps the conditional pop (currentHomeAddresses.size < 2) and Summary keeps its two-step terminal pop (popUpTo then navigateAndPopUpTo). - insurance-certificate uses inclusive=true while travel-certificate uses inclusive=false — intentionally different back behavior, left untouched. - terminate-insurance: both Terminated and Deleted route to TerminationSuccessKey via navigateAndPopUpTo(inclusive=true). - help-center: same-module quick links (FirstVet, SickAbroad) navigate directly, but cross-module OuterDestination intentionally retains the minimal flag dance since cross-module targets must stay injected lambdas. Cross-module navigation, pure UI-button navigation (navigateUp/popBackstack/ exitFlow/goHome), and GlobalSnackBarState overlays are deliberately left as-is. --- .../navigation/AddonPurchaseEntries.kt | 17 +-- .../ui/customize/CustomizeAddonDestination.kt | 17 --- .../ui/customize/CustomizeAddonViewModel.kt | 39 ++---- .../SelectInsuranceForAddonDestination.kt | 15 --- .../SelectInsuranceForAddonViewModel.kt | 31 ++--- .../ui/summary/AddonSummaryDestination.kt | 33 +---- .../ui/summary/AddonSummaryViewModel.kt | 49 +++----- .../kotlin/ui/AddonSummaryPresenterTest.kt | 37 ++++-- .../ui/CustomizeTravelAddonPresenterTest.kt | 13 +- .../SelectInsuranceForAddonPresenterTest.kt | 39 ++++-- .../src/test/kotlin/ui/TestBackstack.kt | 8 ++ .../chip/id/navigation/ChipIdEntries.kt | 8 -- .../SelectInsuranceForChipIdDestination.kt | 41 ++----- .../SelectInsuranceForChipIdViewModel.kt | 40 +++--- .../tier/navigation/ChooseTierEntries.kt | 44 ------- .../ChooseInsuranceToChangeTierDestination.kt | 34 +----- .../ChooseInsuranceViewModel.kt | 33 +++-- .../stepcustomize/SelectCoverageViewModel.kt | 55 +++++---- .../ui/stepcustomize/SelectTierDestination.kt | 22 ---- .../ui/stepstart/StartTierFlowDestination.kt | 22 ---- .../ui/stepstart/StartTierFlowViewModel.kt | 14 ++- .../tier/ui/stepsummary/SummaryDestination.kt | 31 +---- .../tier/ui/stepsummary/SummaryViewModel.kt | 42 +++---- .../src/test/kotlin/CommonTestdata.kt | 6 + .../ChooseInsurancePresenterTest.kt | 70 ++++++----- .../SelectCoveragePresenterTest.kt | 31 +++-- .../stepstart/StartTierChangePresenterTest.kt | 18 ++- .../navigation/EditCoInsuredDestinations.kt | 27 ++++ .../navigation/EditCoInsuredEntries.kt | 39 ------ .../EditCoInsuredAddMissingInfoDestination.kt | 18 +-- .../ui/EditCoInsuredAddOrRemoveDestination.kt | 19 +-- .../ui/EditCoInsuredPresenter.kt | 13 +- .../ui/EditCoInsuredViewModel.kt | 3 + .../triage/EditCoInsuredTriageDestination.kt | 51 ++------ .../ui/triage/EditCoInsuredTriageViewModel.kt | 61 ++++------ .../ui/EditCoInsuredPresenterTest.kt | 3 + .../feature/editcoinsured/ui/TestBackstack.kt | 8 ++ .../feature/help/center/HelpCenterEntries.kt | 21 +--- .../help/center/HelpCenterPresenter.kt | 22 +++- .../help/center/HelpCenterViewModel.kt | 3 + .../center/home/HelpCenterHomeDestination.kt | 2 +- .../navigation/InsuranceEvidenceEntries.kt | 7 -- .../InsuranceEvidenceEmailInputDestination.kt | 17 --- .../InsuranceEvidenceEmailInputViewModel.kt | 22 ++-- ...nsuranceEvidenceEmailInputPresenterTest.kt | 58 ++++----- .../feature/movingflow/MovingFlowEntries.kt | 42 +------ .../AddHouseInformationDestination.kt | 10 -- .../AddHouseInformationViewModel.kt | 21 ++-- ...seCoverageLevelAndDeductibleDestination.kt | 18 --- ...hoseCoverageLevelAndDeductibleViewModel.kt | 45 +++---- .../EnterNewAddressDestination.kt | 16 --- .../EnterNewAddressViewModel.kt | 37 ++---- .../SelectContractDestination.kt | 13 -- .../selectcontract/SelectContractViewModel.kt | 35 +++--- .../ui/start/HousingTypeDestination.kt | 19 +-- .../ui/start/HousingTypeViewModel.kt | 45 ++++--- .../ui/summary/SummaryDestination.kt | 8 -- .../movingflow/ui/summary/SummaryViewModel.kt | 25 ++-- .../payments/navigation/PaymentsEntries.kt | 9 -- .../manualcharge/ManualChargeDestination.kt | 27 +--- .../ui/manualcharge/ManualChargeViewModel.kt | 25 ++-- .../navigation/PayoutAccountEntries.kt | 9 -- .../EditBankAccountDestination.kt | 6 +- .../EditBankAccountViewModel.kt | 9 +- .../SetupInvoicePayoutDestination.kt | 6 +- .../SetupInvoicePayoutViewModel.kt | 9 +- .../setupswish/SetupSwishPayoutDestination.kt | 6 +- .../setupswish/SetupSwishPayoutViewModel.kt | 9 +- .../feature-remove-addons/build.gradle.kts | 4 +- .../remove/addons/RemoveAddonsEntries.kt | 105 ---------------- .../remove/addons/RemoveAddonsNavKeys.kt | 66 ++++++++++ .../ui/RemoveAddonSummaryDestination.kt | 37 +----- .../addons/ui/RemoveAddonSummaryViewModel.kt | 51 +++----- .../ui/SelectAddonToRemoveDestination.kt | 46 +------ .../addons/ui/SelectAddonToRemoveViewModel.kt | 93 +++++++------- ...SelectInsuranceToRemoveAddonDestination.kt | 19 +-- .../SelectInsuranceToRemoveAddonViewModel.kt | 29 +++-- .../navigation/TerminateInsuranceEntries.kt | 115 +----------------- .../ChooseInsuranceToTerminateDestination.kt | 12 -- .../ChooseInsuranceToTerminateViewModel.kt | 37 +++--- .../survey/TerminationSurveyDestination.kt | 22 +--- .../step/survey/TerminationSurveyViewModel.kt | 96 +++++++++------ .../TerminationConfirmationDestination.kt | 12 +- .../TerminationConfirmationViewModel.kt | 30 ++--- .../terminateinsurance/TestBackstack.kt | 8 ++ .../survey/TerminationSurveyPresenterTest.kt | 81 ++++++++---- .../TerminationConfirmationPresenterTest.kt | 68 +++++++---- .../navigation/TravelCertificateEntries.kt | 20 --- .../TravelCertificateDateInput.kt | 35 ------ .../TravelCertificateDateInputViewModel.kt | 54 +++----- .../TravelCertificateTravellersInput.kt | 13 -- ...avelCertificateTravellersInputViewModel.kt | 20 ++- 92 files changed, 956 insertions(+), 1769 deletions(-) create mode 100644 app/feature/feature-addon-purchase/src/test/kotlin/ui/TestBackstack.kt create mode 100644 app/feature/feature-edit-coinsured/src/test/kotlin/com/hedvig/android/feature/editcoinsured/ui/TestBackstack.kt create mode 100644 app/feature/feature-remove-addons/src/commonMain/kotlin/com/hedvig/feature/remove/addons/RemoveAddonsNavKeys.kt create mode 100644 app/feature/feature-terminate-insurance/src/test/kotlin/com/hedvig/android/feature/terminateinsurance/TestBackstack.kt diff --git a/app/feature/feature-addon-purchase/src/main/kotlin/com/hedvig/android/feature/addon/purchase/navigation/AddonPurchaseEntries.kt b/app/feature/feature-addon-purchase/src/main/kotlin/com/hedvig/android/feature/addon/purchase/navigation/AddonPurchaseEntries.kt index 4479a6d87a..15dd3d907a 100644 --- a/app/feature/feature-addon-purchase/src/main/kotlin/com/hedvig/android/feature/addon/purchase/navigation/AddonPurchaseEntries.kt +++ b/app/feature/feature-addon-purchase/src/main/kotlin/com/hedvig/android/feature/addon/purchase/navigation/AddonPurchaseEntries.kt @@ -96,15 +96,12 @@ fun EntryProviderScope.addonPurchaseEntries( } else { val viewModel: SelectInsuranceForAddonViewModel = assistedMetroViewModel { - create(insuranceIds) + create(insuranceIds, preselectedAddonDisplayNames) } SelectInsuranceForAddonDestination( viewModel = viewModel, navigateUp = backstack::navigateUp, popBackstack = popBackstack, - navigateToCustomizeAddon = { chosenInsuranceId: String -> - backstack.add(CustomizeAddonKey(chosenInsuranceId, preselectedAddonDisplayNames)) - }, ) } } @@ -139,15 +136,6 @@ fun EntryProviderScope.addonPurchaseEntries( viewModel = viewModel, navigateUp = backstack::navigateUp, navigateBack = popBackstack, - onFailure = { - backstack.add(SubmitFailureKey) - }, - onSuccess = { - backstack.navigateAndPopUpTo( - SubmitSuccessKey(key.params.activationDate), - inclusive = true, - ) - }, ) } @@ -190,9 +178,6 @@ private fun CustomizeAddonContent( finishApp() } }, - navigateToSummary = { summaryParameters: SummaryParameters -> - backstack.add(SummaryKey(summaryParameters)) - }, onNavigateToTravelInsurancePlusExplanation = { perilData -> backstack.add(TravelInsurancePlusExplanationKey(perilData)) }, diff --git a/app/feature/feature-addon-purchase/src/main/kotlin/com/hedvig/android/feature/addon/purchase/ui/customize/CustomizeAddonDestination.kt b/app/feature/feature-addon-purchase/src/main/kotlin/com/hedvig/android/feature/addon/purchase/ui/customize/CustomizeAddonDestination.kt index 62c5fd6ef9..1d6ffdf038 100644 --- a/app/feature/feature-addon-purchase/src/main/kotlin/com/hedvig/android/feature/addon/purchase/ui/customize/CustomizeAddonDestination.kt +++ b/app/feature/feature-addon-purchase/src/main/kotlin/com/hedvig/android/feature/addon/purchase/ui/customize/CustomizeAddonDestination.kt @@ -23,7 +23,6 @@ import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -97,13 +96,11 @@ import com.hedvig.android.feature.addon.purchase.data.AddonQuote import com.hedvig.android.feature.addon.purchase.data.CurrentlyActiveAddon import com.hedvig.android.feature.addon.purchase.data.TravelAddonQuoteInsuranceDocument import com.hedvig.android.feature.addon.purchase.navigation.PerilComparisonParams -import com.hedvig.android.feature.addon.purchase.navigation.SummaryParameters import com.hedvig.android.feature.addon.purchase.navigation.TravelInsurancePlusExplanationKey import com.hedvig.android.feature.addon.purchase.ui.customize.CustomizeAddonState.Failure import com.hedvig.android.feature.addon.purchase.ui.customize.CustomizeAddonState.Loading import com.hedvig.android.feature.addon.purchase.ui.customize.CustomizeTravelAddonEvent.ChooseOptionInDialog import com.hedvig.android.feature.addon.purchase.ui.customize.CustomizeTravelAddonEvent.ChooseSelectedOption -import com.hedvig.android.feature.addon.purchase.ui.customize.CustomizeTravelAddonEvent.ClearNavigation import com.hedvig.android.feature.addon.purchase.ui.customize.CustomizeTravelAddonEvent.Reload import com.hedvig.android.feature.addon.purchase.ui.customize.CustomizeTravelAddonEvent.SetSelectedOptionBackToPreviouslyChosen import com.hedvig.android.feature.addon.purchase.ui.customize.CustomizeTravelAddonEvent.SubmitSelected @@ -132,7 +129,6 @@ internal fun CustomizeAddonDestination( navigateUp: () -> Unit, popBackstack: () -> Unit, popAddonFlow: () -> Unit, - navigateToSummary: (summaryParameters: SummaryParameters) -> Unit, onNavigateToTravelInsurancePlusExplanation: (PerilComparisonParams) -> Unit, navigateToChangeTier: (contractId: String) -> Unit, ) { @@ -160,10 +156,6 @@ internal fun CustomizeAddonDestination( onSetOptionBackToPreviouslyChosen = { viewModel.emit(SetSelectedOptionBackToPreviouslyChosen) }, - navigateToSummary = { params -> - viewModel.emit(ClearNavigation) - navigateToSummary(params) - }, onNavigateToTravelInsurancePlusExplanation = onNavigateToTravelInsurancePlusExplanation, onToggleOption = { viewModel.emit(CustomizeTravelAddonEvent.ToggleOption(it)) @@ -179,7 +171,6 @@ private fun CustomizeTravelAddonScreen( popBackstack: () -> Unit, submitSelected: () -> Unit, submitToggled: () -> Unit, - navigateToSummary: (summaryParameters: SummaryParameters) -> Unit, onChooseOptionInDialog: (AddonQuote) -> Unit, onToggleOption: (AddonQuote) -> Unit, onChooseSelectedOption: () -> Unit, @@ -207,12 +198,6 @@ private fun CustomizeTravelAddonScreen( } is CustomizeAddonState.Success -> { - LaunchedEffect(uiState.commonParams.summaryParamsToNavigateFurther) { - val summaryParams = uiState.commonParams.summaryParamsToNavigateFurther - if (summaryParams != null) { - navigateToSummary(summaryParams) - } - } CustomizeSelectableAddonScreenContent( uiState = uiState, navigateUp = navigateUp, @@ -882,7 +867,6 @@ private fun SelectTierScreenPreview( {}, {}, {}, - {}, ) } } @@ -897,7 +881,6 @@ internal class CustomizeTravelAddonPreviewProvider : currentlyChosenOption = fakeAddonQuote1, currentlyChosenOptionInDialog = fakeAddonQuote1, commonParams = CommonSuccessParameters( - summaryParamsToNavigateFurther = null, umbrellaDisplayTitle = "Display title", umbrellaDisplayDescription = "Display description", activationDate = LocalDate(2026, 2, 20), diff --git a/app/feature/feature-addon-purchase/src/main/kotlin/com/hedvig/android/feature/addon/purchase/ui/customize/CustomizeAddonViewModel.kt b/app/feature/feature-addon-purchase/src/main/kotlin/com/hedvig/android/feature/addon/purchase/ui/customize/CustomizeAddonViewModel.kt index d90075b484..614cc747ac 100644 --- a/app/feature/feature-addon-purchase/src/main/kotlin/com/hedvig/android/feature/addon/purchase/ui/customize/CustomizeAddonViewModel.kt +++ b/app/feature/feature-addon-purchase/src/main/kotlin/com/hedvig/android/feature/addon/purchase/ui/customize/CustomizeAddonViewModel.kt @@ -21,11 +21,14 @@ import com.hedvig.android.feature.addon.purchase.data.CurrentlyActiveAddon import com.hedvig.android.feature.addon.purchase.data.GenerateAddonOfferResult import com.hedvig.android.feature.addon.purchase.data.GetAddonOfferUseCase import com.hedvig.android.feature.addon.purchase.navigation.AddonType +import com.hedvig.android.feature.addon.purchase.navigation.SummaryKey import com.hedvig.android.feature.addon.purchase.navigation.SummaryParameters import com.hedvig.android.feature.addon.purchase.ui.customize.updateTotalExtraForSelectedToggleable import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel +import com.hedvig.android.navigation.compose.Backstack +import com.hedvig.android.navigation.compose.add import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject @@ -39,12 +42,14 @@ internal class CustomizeAddonViewModel( @Assisted insuranceId: String, @Assisted preselectedAddonDisplayNames: List, getAddonOfferUseCase: GetAddonOfferUseCase, + backstack: Backstack, ) : MoleculeViewModel( initialState = CustomizeAddonState.Loading, presenter = CustomizeTravelAddonPresenter( insuranceId = insuranceId, preselectedAddonDisplayNames = preselectedAddonDisplayNames, getAddonOfferUseCase = getAddonOfferUseCase, + backstack = backstack, ), ) { @AssistedFactory @@ -62,6 +67,7 @@ internal class CustomizeTravelAddonPresenter( private val insuranceId: String, private val preselectedAddonDisplayNames: List, private val getAddonOfferUseCase: GetAddonOfferUseCase, + private val backstack: Backstack, ) : MoleculePresenter { @Composable override fun MoleculePresenterScope.present( @@ -110,23 +116,6 @@ internal class CustomizeTravelAddonPresenter( selectedOptionInDialog = state.currentlyChosenOption } - CustomizeTravelAddonEvent.ClearNavigation -> { - val state = currentState as? CustomizeAddonState.Success ?: return@CollectEvents - currentState = when (state) { - is CustomizeAddonState.Success.Selectable -> state.copy( - commonParams = state.commonParams.copy( - summaryParamsToNavigateFurther = null, - ), - ) - - is CustomizeAddonState.Success.Toggleable -> state.copy( - commonParams = state.commonParams.copy( - summaryParamsToNavigateFurther = null, - ), - ) - } - } - CustomizeTravelAddonEvent.SubmitSelected -> { val state = currentState as? CustomizeAddonState.Success.Selectable ?: return@CollectEvents val summaryParams = SummaryParameters( @@ -142,11 +131,7 @@ internal class CustomizeTravelAddonPresenter( productVariant = state.commonParams.productVariant, addonType = AddonType.SELECTABLE, ) - currentState = state.copy( - commonParams = state.commonParams.copy( - summaryParamsToNavigateFurther = summaryParams, - ), - ) + backstack.add(SummaryKey(summaryParams)) } CustomizeTravelAddonEvent.SubmitToggled -> { @@ -162,11 +147,7 @@ internal class CustomizeTravelAddonPresenter( contractId = state.commonParams.contractId, addonType = AddonType.TOGGLEABLE, ) - currentState = state.copy( - commonParams = state.commonParams.copy( - summaryParamsToNavigateFurther = summaryParams, - ), - ) + backstack.add(SummaryKey(summaryParams)) } is CustomizeTravelAddonEvent.ToggleOption -> { @@ -213,7 +194,6 @@ internal class CustomizeTravelAddonPresenter( notificationMessage = result.notificationMessage, productVariant = result.umbrellaAddonQuote.productVariant, contractId = result.contractId, - summaryParamsToNavigateFurther = null, whatsIncludedPageTitle = result.whatsIncludedPageTitle, whatsIncludedPageDescription = result.whatsIncludedPageDescription, ) @@ -350,7 +330,6 @@ internal data class CommonSuccessParameters( val quoteId: String, val activationDate: LocalDate, val baseQuoteCost: ItemCost, - val summaryParamsToNavigateFurther: SummaryParameters?, val notificationMessage: String?, val productVariant: ProductVariant, val contractId: String, @@ -367,8 +346,6 @@ internal sealed interface CustomizeTravelAddonEvent { data object SetSelectedOptionBackToPreviouslyChosen : CustomizeTravelAddonEvent - data object ClearNavigation : CustomizeTravelAddonEvent - data object SubmitSelected : CustomizeTravelAddonEvent data class ToggleOption(val option: AddonQuote) : CustomizeTravelAddonEvent diff --git a/app/feature/feature-addon-purchase/src/main/kotlin/com/hedvig/android/feature/addon/purchase/ui/selectinsurance/SelectInsuranceForAddonDestination.kt b/app/feature/feature-addon-purchase/src/main/kotlin/com/hedvig/android/feature/addon/purchase/ui/selectinsurance/SelectInsuranceForAddonDestination.kt index 7726d7cf5e..b1c292e83e 100644 --- a/app/feature/feature-addon-purchase/src/main/kotlin/com/hedvig/android/feature/addon/purchase/ui/selectinsurance/SelectInsuranceForAddonDestination.kt +++ b/app/feature/feature-addon-purchase/src/main/kotlin/com/hedvig/android/feature/addon/purchase/ui/selectinsurance/SelectInsuranceForAddonDestination.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.PreviewParameter @@ -48,17 +47,12 @@ internal fun SelectInsuranceForAddonDestination( viewModel: SelectInsuranceForAddonViewModel, navigateUp: () -> Unit, popBackstack: () -> Unit, - navigateToCustomizeAddon: (chosenInsuranceId: String) -> Unit, ) { val uiState: SelectInsuranceForAddonState by viewModel.uiState.collectAsStateWithLifecycle() SelectInsuranceForAddonScreen( uiState = uiState, navigateUp = navigateUp, popBackstack = popBackstack, - navigateToCustomizeAddon = { id -> - navigateToCustomizeAddon(id) - viewModel.emit(SelectInsuranceForAddonEvent.ClearNavigation) - }, selectInsurance = { selected -> viewModel.emit(SelectInsuranceForAddonEvent.SelectInsurance(selected)) }, @@ -79,7 +73,6 @@ private fun SelectInsuranceForAddonScreen( reload: () -> Unit, selectInsurance: (selected: InsuranceForAddon) -> Unit, submitSelected: (selected: InsuranceForAddon) -> Unit, - navigateToCustomizeAddon: (chosenInsuranceId: String) -> Unit, ) { when (uiState) { Failure -> { @@ -95,11 +88,6 @@ private fun SelectInsuranceForAddonScreen( } is Success -> { - LaunchedEffect(uiState.insuranceIdToContinue) { - if (uiState.insuranceIdToContinue != null) { - navigateToCustomizeAddon(uiState.insuranceIdToContinue) - } - } SelectInsuranceForAddonContentScreen( uiState = uiState, popBackstack = popBackstack, @@ -189,7 +177,6 @@ private fun PreviewChooseInsuranceToTerminateScreen( {}, {}, {}, - {}, ) } } @@ -214,7 +201,6 @@ private class ChooseInsuranceForAddonUiStateProvider : ), ), currentlySelected = null, - insuranceIdToContinue = null, ), Success( listOfInsurances = listOf( @@ -237,7 +223,6 @@ private class ChooseInsuranceForAddonUiStateProvider : contractExposure = "Opulullegatan 19", contractGroup = ContractGroup.HOUSE, ), - insuranceIdToContinue = null, ), Failure, Loading, diff --git a/app/feature/feature-addon-purchase/src/main/kotlin/com/hedvig/android/feature/addon/purchase/ui/selectinsurance/SelectInsuranceForAddonViewModel.kt b/app/feature/feature-addon-purchase/src/main/kotlin/com/hedvig/android/feature/addon/purchase/ui/selectinsurance/SelectInsuranceForAddonViewModel.kt index 281b40cbf7..25a7d3f1d7 100644 --- a/app/feature/feature-addon-purchase/src/main/kotlin/com/hedvig/android/feature/addon/purchase/ui/selectinsurance/SelectInsuranceForAddonViewModel.kt +++ b/app/feature/feature-addon-purchase/src/main/kotlin/com/hedvig/android/feature/addon/purchase/ui/selectinsurance/SelectInsuranceForAddonViewModel.kt @@ -10,9 +10,12 @@ import androidx.compose.runtime.setValue import com.hedvig.android.core.common.di.AppScope import com.hedvig.android.feature.addon.purchase.data.GetInsuranceForTravelAddonUseCase import com.hedvig.android.feature.addon.purchase.data.InsuranceForAddon +import com.hedvig.android.feature.addon.purchase.navigation.CustomizeAddonKey import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel +import com.hedvig.android.navigation.compose.Backstack +import com.hedvig.android.navigation.compose.add import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject @@ -23,12 +26,16 @@ import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactoryKey @AssistedInject internal class SelectInsuranceForAddonViewModel( @Assisted ids: List, + @Assisted preselectedAddonDisplayNames: List, getInsuranceForTravelAddonUseCase: GetInsuranceForTravelAddonUseCase, + backstack: Backstack, ) : MoleculeViewModel( initialState = SelectInsuranceForAddonState.Loading, presenter = SelectInsuranceForAddonPresenter( ids = ids, + preselectedAddonDisplayNames = preselectedAddonDisplayNames, getInsuranceForTravelAddonUseCase = getInsuranceForTravelAddonUseCase, + backstack = backstack, ), ) { @AssistedFactory @@ -37,13 +44,16 @@ internal class SelectInsuranceForAddonViewModel( fun interface Factory : ManualViewModelAssistedFactory { fun create( @Assisted ids: List, + @Assisted preselectedAddonDisplayNames: List, ): SelectInsuranceForAddonViewModel } } internal class SelectInsuranceForAddonPresenter( private val ids: List, + private val preselectedAddonDisplayNames: List, private val getInsuranceForTravelAddonUseCase: GetInsuranceForTravelAddonUseCase, + private val backstack: Backstack, ) : MoleculePresenter { @Composable override fun MoleculePresenterScope.present( @@ -64,15 +74,8 @@ internal class SelectInsuranceForAddonPresenter( is SelectInsuranceForAddonEvent.SubmitSelected -> { val state = currentState as? SelectInsuranceForAddonState.Success ?: return@CollectEvents - currentState = state.copy( - currentlySelected = event.selected, - insuranceIdToContinue = event.selected.id, - ) - } - - SelectInsuranceForAddonEvent.ClearNavigation -> { - val state = currentState as? SelectInsuranceForAddonState.Success ?: return@CollectEvents - currentState = state.copy(insuranceIdToContinue = null) + currentState = state.copy(currentlySelected = event.selected) + backstack.add(CustomizeAddonKey(event.selected.id, preselectedAddonDisplayNames)) } } } @@ -84,11 +87,7 @@ internal class SelectInsuranceForAddonPresenter( currentState = SelectInsuranceForAddonState.Failure } else if (ids.size == 1) { // should be impossible: we reroute earlier in the navigation entries - currentState = SelectInsuranceForAddonState.Success( - listOfInsurances = emptyList(), - insuranceIdToContinue = ids[0], - currentlySelected = null, - ) + backstack.add(CustomizeAddonKey(ids[0], preselectedAddonDisplayNames)) } else { getInsuranceForTravelAddonUseCase.invoke(ids).collect { result -> result.fold( @@ -98,7 +97,6 @@ internal class SelectInsuranceForAddonPresenter( ifRight = { loadedInsurances -> currentState = SelectInsuranceForAddonState.Success( listOfInsurances = loadedInsurances, - insuranceIdToContinue = null, currentlySelected = null, ) }, @@ -116,7 +114,6 @@ internal sealed interface SelectInsuranceForAddonState { data class Success( val listOfInsurances: List, val currentlySelected: InsuranceForAddon?, - val insuranceIdToContinue: String? = null, ) : SelectInsuranceForAddonState data object Failure : SelectInsuranceForAddonState @@ -128,6 +125,4 @@ internal sealed interface SelectInsuranceForAddonEvent { data class SelectInsurance(val selected: InsuranceForAddon) : SelectInsuranceForAddonEvent data class SubmitSelected(val selected: InsuranceForAddon) : SelectInsuranceForAddonEvent - - data object ClearNavigation : SelectInsuranceForAddonEvent } diff --git a/app/feature/feature-addon-purchase/src/main/kotlin/com/hedvig/android/feature/addon/purchase/ui/summary/AddonSummaryDestination.kt b/app/feature/feature-addon-purchase/src/main/kotlin/com/hedvig/android/feature/addon/purchase/ui/summary/AddonSummaryDestination.kt index 314710086d..2d4cabec41 100644 --- a/app/feature/feature-addon-purchase/src/main/kotlin/com/hedvig/android/feature/addon/purchase/ui/summary/AddonSummaryDestination.kt +++ b/app/feature/feature-addon-purchase/src/main/kotlin/com/hedvig/android/feature/addon/purchase/ui/summary/AddonSummaryDestination.kt @@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -77,20 +76,10 @@ internal fun AddonSummaryDestination( viewModel: AddonSummaryViewModel, navigateBack: () -> Unit, navigateUp: () -> Unit, - onFailure: () -> Unit, - onSuccess: (activationDate: LocalDate) -> Unit, ) { val uiState: AddonSummaryState by viewModel.uiState.collectAsStateWithLifecycle() AddonSummaryScreen( uiState = uiState, - onSuccess = { date -> - viewModel.emit(AddonSummaryEvent.ReturnToInitialState) - onSuccess(date) - }, - onFailure = { - viewModel.emit(AddonSummaryEvent.ReturnToInitialState) - onFailure() - }, navigateUp = navigateUp, navigateBack = navigateBack, onSubmitQuoteClick = { @@ -105,33 +94,17 @@ internal fun AddonSummaryDestination( @Composable private fun AddonSummaryScreen( uiState: AddonSummaryState, - onSuccess: (LocalDate) -> Unit, navigateBack: () -> Unit, navigateUp: () -> Unit, - onFailure: () -> Unit, reload: () -> Unit, onSubmitQuoteClick: () -> Unit, ) { when (uiState) { is Loading -> { - LaunchedEffect(uiState.activationDateToNavigateToSuccess) { - val date = uiState.activationDateToNavigateToSuccess - if (date != null) { - onSuccess(date) - } - } - HedvigFullScreenCenterAlignedProgress() } is Content -> { - LaunchedEffect(uiState.navigateToFailure) { - val fail = uiState.navigateToFailure - if (fail != null) { - onFailure() - } - } - SummarySuccessScreen( uiState = uiState, navigateBack = navigateBack, @@ -325,8 +298,6 @@ private fun PreviewAddonSummaryScreen( {}, {}, {}, - {}, - {}, ) } } @@ -335,7 +306,7 @@ private fun PreviewAddonSummaryScreen( private class ChooseInsuranceForAddonUiStateProvider : CollectionPreviewParameterProvider( listOf( - Loading(activationDateToNavigateToSuccess = null), + Loading, Content( currentlyActiveAddons = listOf( CurrentlyActiveAddon( @@ -387,7 +358,6 @@ private class ChooseInsuranceForAddonUiStateProvider : addonSubtype = "DAYS_60", ), ), - navigateToFailure = null, insuranceExposure = "Exposure", notificationMessage = "Notification message", documents = emptyList(), @@ -458,7 +428,6 @@ private class ChooseInsuranceForAddonUiStateProvider : addonSubtype = "DAYS_60", ), ), - navigateToFailure = null, insuranceExposure = "Exposure", notificationMessage = "Notification message", documents = emptyList(), diff --git a/app/feature/feature-addon-purchase/src/main/kotlin/com/hedvig/android/feature/addon/purchase/ui/summary/AddonSummaryViewModel.kt b/app/feature/feature-addon-purchase/src/main/kotlin/com/hedvig/android/feature/addon/purchase/ui/summary/AddonSummaryViewModel.kt index 9e6c12b1af..2b87281b14 100644 --- a/app/feature/feature-addon-purchase/src/main/kotlin/com/hedvig/android/feature/addon/purchase/ui/summary/AddonSummaryViewModel.kt +++ b/app/feature/feature-addon-purchase/src/main/kotlin/com/hedvig/android/feature/addon/purchase/ui/summary/AddonSummaryViewModel.kt @@ -7,7 +7,6 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import com.hedvig.android.core.common.ErrorMessage import com.hedvig.android.core.common.di.AppScope import com.hedvig.android.core.tracking.ActionType import com.hedvig.android.core.tracking.logAction @@ -20,7 +19,10 @@ import com.hedvig.android.feature.addon.purchase.data.CurrentlyActiveAddon import com.hedvig.android.feature.addon.purchase.data.GetInsuranceForTravelAddonUseCase import com.hedvig.android.feature.addon.purchase.data.GetQuoteCostBreakdownUseCase import com.hedvig.android.feature.addon.purchase.data.SubmitAddonPurchaseUseCase +import com.hedvig.android.feature.addon.purchase.navigation.AddonPurchaseKey import com.hedvig.android.feature.addon.purchase.navigation.AddonType +import com.hedvig.android.feature.addon.purchase.navigation.SubmitFailureKey +import com.hedvig.android.feature.addon.purchase.navigation.SubmitSuccessKey import com.hedvig.android.feature.addon.purchase.navigation.SummaryParameters import com.hedvig.android.feature.addon.purchase.ui.summary.AddonLogInfo.AddonEventType import com.hedvig.android.feature.addon.purchase.ui.summary.AddonSummaryState.Content @@ -28,6 +30,9 @@ import com.hedvig.android.feature.addon.purchase.ui.summary.AddonSummaryState.Lo import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel +import com.hedvig.android.navigation.compose.Backstack +import com.hedvig.android.navigation.compose.add +import com.hedvig.android.navigation.compose.navigateAndPopUpTo import com.hedvig.ui.tiersandaddons.CostBreakdownEntry import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory @@ -45,14 +50,16 @@ internal class AddonSummaryViewModel( submitAddonPurchaseUseCase: SubmitAddonPurchaseUseCase, getQuoteCostBreakdownUseCase: GetQuoteCostBreakdownUseCase, getInsuranceForTravelAddonUseCase: GetInsuranceForTravelAddonUseCase, + backstack: Backstack, ) : MoleculeViewModel( - initialState = Loading(), + initialState = Loading, presenter = AddonSummaryPresenter( summaryParameters, submitAddonPurchaseUseCase, addonPurchaseSource, getQuoteCostBreakdownUseCase, getInsuranceForTravelAddonUseCase, + backstack, ), ) { @AssistedFactory @@ -72,14 +79,13 @@ internal class AddonSummaryPresenter( private val addonPurchaseSource: AddonBannerSource, private val getQuoteCostBreakdownUseCase: GetQuoteCostBreakdownUseCase, private val getInsuranceForTravelAddonUseCase: GetInsuranceForTravelAddonUseCase, + private val backstack: Backstack, ) : MoleculePresenter { @Composable override fun MoleculePresenterScope.present(lastState: AddonSummaryState): AddonSummaryState { var submitIteration by remember { mutableIntStateOf(0) } var loadIteration by remember { mutableIntStateOf(0) } var currentState by remember { mutableStateOf(lastState) } - var activationDateForNavigation by remember { mutableStateOf(null) } - var errorForNavigation by remember { mutableStateOf(null) } CollectEvents { event -> when (event) { @@ -87,11 +93,6 @@ internal class AddonSummaryPresenter( submitIteration++ } - AddonSummaryEvent.ReturnToInitialState -> { - activationDateForNavigation = null - errorForNavigation = null - } - AddonSummaryEvent.Reload -> { loadIteration++ } @@ -150,7 +151,7 @@ internal class AddonSummaryPresenter( LaunchedEffect(submitIteration) { val state = currentState as? Content ?: return@LaunchedEffect if (submitIteration > 0) { - currentState = Loading() + currentState = Loading submitAddonPurchaseUseCase.invoke( quoteId = summaryParameters.quoteId, addonIds = summaryParameters.chosenQuotes.map { @@ -158,28 +159,20 @@ internal class AddonSummaryPresenter( }, ).fold( ifLeft = { - errorForNavigation = it currentState = state + backstack.add(SubmitFailureKey) }, ifRight = { logSuccessfulAddonPurchaseAction(summaryParameters, addonPurchaseSource) - errorForNavigation = null - activationDateForNavigation = summaryParameters.activationDate + backstack.navigateAndPopUpTo( + SubmitSuccessKey(summaryParameters.activationDate), + inclusive = true, + ) }, ) } } - return when (val state = currentState) { - is Content -> state.copy( - navigateToFailure = errorForNavigation, - ) - - is Loading -> state.copy( - activationDateToNavigateToSuccess = activationDateForNavigation, - ) - - AddonSummaryState.Error -> state - } + return currentState } } @@ -201,7 +194,6 @@ internal fun getInitialState( // it.displayDetails // }, displayItems = emptyList(), // todo: check on test session - navigateToFailure = null, contractGroup = summaryParameters.productVariant.contractGroup, ) } @@ -214,9 +206,7 @@ internal data class CostBreakdownWithExtras( ) internal sealed interface AddonSummaryState { - data class Loading( - val activationDateToNavigateToSuccess: LocalDate? = null, - ) : AddonSummaryState + data object Loading : AddonSummaryState data object Error : AddonSummaryState @@ -231,7 +221,6 @@ internal sealed interface AddonSummaryState { val documents: List, val costBreakdownWithExtras: CostBreakdownWithExtras, val displayItems: List>, // todo: check how those look - val navigateToFailure: ErrorMessage? = null, ) : AddonSummaryState } @@ -239,8 +228,6 @@ internal sealed interface AddonSummaryEvent { data object Submit : AddonSummaryEvent data object Reload : AddonSummaryEvent - - data object ReturnToInitialState : AddonSummaryEvent } private fun logSuccessfulAddonPurchaseAction( diff --git a/app/feature/feature-addon-purchase/src/test/kotlin/ui/AddonSummaryPresenterTest.kt b/app/feature/feature-addon-purchase/src/test/kotlin/ui/AddonSummaryPresenterTest.kt index 6354d8287a..ad734b6e3f 100644 --- a/app/feature/feature-addon-purchase/src/test/kotlin/ui/AddonSummaryPresenterTest.kt +++ b/app/feature/feature-addon-purchase/src/test/kotlin/ui/AddonSummaryPresenterTest.kt @@ -7,7 +7,6 @@ import arrow.core.right import assertk.assertThat import assertk.assertions.isEqualTo import assertk.assertions.isInstanceOf -import assertk.assertions.isNotNull import assertk.assertions.prop import com.hedvig.android.core.common.ErrorMessage import com.hedvig.android.core.uidata.ItemCost @@ -25,7 +24,10 @@ import com.hedvig.android.feature.addon.purchase.data.GetInsuranceForTravelAddon import com.hedvig.android.feature.addon.purchase.data.GetQuoteCostBreakdownUseCase import com.hedvig.android.feature.addon.purchase.data.InsuranceForAddon import com.hedvig.android.feature.addon.purchase.data.SubmitAddonPurchaseUseCase +import com.hedvig.android.feature.addon.purchase.navigation.AddonPurchaseKey import com.hedvig.android.feature.addon.purchase.navigation.AddonType +import com.hedvig.android.feature.addon.purchase.navigation.SubmitFailureKey +import com.hedvig.android.feature.addon.purchase.navigation.SubmitSuccessKey import com.hedvig.android.feature.addon.purchase.navigation.SummaryParameters import com.hedvig.android.feature.addon.purchase.ui.summary.AddonSummaryEvent import com.hedvig.android.feature.addon.purchase.ui.summary.AddonSummaryPresenter @@ -47,53 +49,59 @@ class AddonSummaryPresenterTest { @Test fun `if receive error navigate to failure screen`() = runTest { + val scheduler = testScheduler val submitUseCase = FakeSubmitAddonPurchaseUseCase() val insuranceUseCase = FakeAddonSummaryGetInsuranceUseCase() val costBreakdownUseCase = FakeGetQuoteCostBreakdownUseCase() + val backstack = TestBackstack(mutableListOf(AddonPurchaseKey())) val presenter = AddonSummaryPresenter( summaryParameters = testSummaryParametersWithCurrentAddon, submitAddonPurchaseUseCase = submitUseCase, addonPurchaseSource = AddonBannerSource.INSURANCES_TAB, getQuoteCostBreakdownUseCase = costBreakdownUseCase, getInsuranceForTravelAddonUseCase = insuranceUseCase, + backstack = backstack, ) - presenter.test(AddonSummaryState.Loading()) { + presenter.test(AddonSummaryState.Loading) { skipItems(1) insuranceUseCase.turbine.add(listOf(fakeInsuranceForAddon).right()) costBreakdownUseCase.turbine.add(fakeQuoteCostBreakdown.right()) skipItems(1) sendEvent(AddonSummaryEvent.Submit) submitUseCase.turbine.add(ErrorMessage().left()) - skipItems(1) - assertThat(awaitItem()).isInstanceOf(AddonSummaryState.Content::class) - .prop(AddonSummaryState.Content::navigateToFailure).isNotNull() + scheduler.advanceUntilIdle() + assertThat(backstack.entries.last()).isInstanceOf(SubmitFailureKey::class) + cancelAndIgnoreRemainingEvents() } } @Test fun `if receive no errors navigate to success screen with activationDate from previous parameters`() = runTest { + val scheduler = testScheduler val submitUseCase = FakeSubmitAddonPurchaseUseCase() val insuranceUseCase = FakeAddonSummaryGetInsuranceUseCase() val costBreakdownUseCase = FakeGetQuoteCostBreakdownUseCase() + val backstack = TestBackstack(mutableListOf(AddonPurchaseKey())) val presenter = AddonSummaryPresenter( summaryParameters = testSummaryParametersWithCurrentAddon, submitAddonPurchaseUseCase = submitUseCase, addonPurchaseSource = AddonBannerSource.INSURANCES_TAB, getQuoteCostBreakdownUseCase = costBreakdownUseCase, getInsuranceForTravelAddonUseCase = insuranceUseCase, + backstack = backstack, ) - presenter.test(AddonSummaryState.Loading()) { + presenter.test(AddonSummaryState.Loading) { skipItems(1) insuranceUseCase.turbine.add(listOf(fakeInsuranceForAddon).right()) costBreakdownUseCase.turbine.add(fakeQuoteCostBreakdown.right()) skipItems(1) sendEvent(AddonSummaryEvent.Submit) submitUseCase.turbine.add(Unit.right()) - skipItems(1) - assertThat(awaitItem()).isInstanceOf(AddonSummaryState.Loading::class) - .prop(AddonSummaryState.Loading::activationDateToNavigateToSuccess) - .isNotNull() + scheduler.advanceUntilIdle() + assertThat(backstack.entries.last()).isInstanceOf(SubmitSuccessKey::class) + .prop(SubmitSuccessKey::activationDate) .isEqualTo(testSummaryParametersWithCurrentAddon.activationDate) + cancelAndIgnoreRemainingEvents() } } @@ -107,8 +115,9 @@ class AddonSummaryPresenterTest { addonPurchaseSource = AddonBannerSource.INSURANCES_TAB, getQuoteCostBreakdownUseCase = costBreakdownUseCase1, getInsuranceForTravelAddonUseCase = insuranceUseCase1, + backstack = TestBackstack(), ) - presenter1.test(AddonSummaryState.Loading()) { + presenter1.test(AddonSummaryState.Loading) { skipItems(1) insuranceUseCase1.turbine.add(listOf(fakeInsuranceForAddon).right()) costBreakdownUseCase1.turbine.add(fakeQuoteCostBreakdown.right()) @@ -126,8 +135,9 @@ class AddonSummaryPresenterTest { addonPurchaseSource = AddonBannerSource.INSURANCES_TAB, getQuoteCostBreakdownUseCase = costBreakdownUseCase2, getInsuranceForTravelAddonUseCase = insuranceUseCase2, + backstack = TestBackstack(), ) - presenter2.test(AddonSummaryState.Loading()) { + presenter2.test(AddonSummaryState.Loading) { skipItems(1) insuranceUseCase2.turbine.add(listOf(fakeInsuranceForAddon).right()) costBreakdownUseCase2.turbine.add(fakeQuoteCostBreakdown.right()) @@ -148,8 +158,9 @@ class AddonSummaryPresenterTest { addonPurchaseSource = AddonBannerSource.INSURANCES_TAB, getQuoteCostBreakdownUseCase = costBreakdownUseCase, getInsuranceForTravelAddonUseCase = insuranceUseCase, + backstack = TestBackstack(), ) - presenter.test(AddonSummaryState.Loading()) { + presenter.test(AddonSummaryState.Loading) { skipItems(1) insuranceUseCase.turbine.add(listOf(fakeInsuranceForAddon).right()) costBreakdownUseCase.turbine.add(fakeQuoteCostBreakdown.right()) diff --git a/app/feature/feature-addon-purchase/src/test/kotlin/ui/CustomizeTravelAddonPresenterTest.kt b/app/feature/feature-addon-purchase/src/test/kotlin/ui/CustomizeTravelAddonPresenterTest.kt index 37b80c226a..50845bf2a6 100644 --- a/app/feature/feature-addon-purchase/src/test/kotlin/ui/CustomizeTravelAddonPresenterTest.kt +++ b/app/feature/feature-addon-purchase/src/test/kotlin/ui/CustomizeTravelAddonPresenterTest.kt @@ -6,9 +6,9 @@ import arrow.core.left import arrow.core.nonEmptyListOf import arrow.core.right import assertk.assertThat +import assertk.assertions.isEmpty import assertk.assertions.isEqualTo import assertk.assertions.isInstanceOf -import assertk.assertions.isNull import assertk.assertions.prop import com.hedvig.android.core.common.ErrorMessage import com.hedvig.android.core.uidata.ItemCost @@ -48,6 +48,7 @@ class CustomizeTravelAddonPresenterTest { getAddonOfferUseCase = useCase, insuranceId = insuranceId, preselectedAddonDisplayNames = emptyList(), + backstack = TestBackstack(), ) presenter.test(CustomizeAddonState.Loading) { skipItems(1) @@ -64,6 +65,7 @@ class CustomizeTravelAddonPresenterTest { getAddonOfferUseCase = useCase, insuranceId = insuranceId, preselectedAddonDisplayNames = emptyList(), + backstack = TestBackstack(), ) presenter.test( CustomizeAddonState.Success.Selectable( @@ -84,10 +86,12 @@ class CustomizeTravelAddonPresenterTest { @Test fun `if receive good response return correct data, pre-choose first addon and do not navigate further`() = runTest { val useCase = FakeGetAddonOfferUseCase() + val backstack = TestBackstack() val presenter = CustomizeTravelAddonPresenter( getAddonOfferUseCase = useCase, insuranceId = insuranceId, preselectedAddonDisplayNames = emptyList(), + backstack = backstack, ) presenter.test( CustomizeAddonState.Loading, @@ -95,11 +99,9 @@ class CustomizeTravelAddonPresenterTest { skipItems(1) useCase.turbine.add(fakeGenerateAddonOfferResultTwoOptions.right()) val state = awaitItem() + assertThat(backstack.entries).isEmpty() assertThat(state).isInstanceOf(CustomizeAddonState.Success.Selectable::class) .apply { - prop(CustomizeAddonState.Success.Selectable::commonParams) - .prop(CommonSuccessParameters::summaryParamsToNavigateFurther) - .isNull() prop(CustomizeAddonState.Success.Selectable::addonOffer).isEqualTo(fakeTravelOfferTwoOptions) prop( CustomizeAddonState.Success.Selectable::currentlyChosenOption, @@ -119,6 +121,7 @@ class CustomizeTravelAddonPresenterTest { getAddonOfferUseCase = useCase, insuranceId = insuranceId, preselectedAddonDisplayNames = emptyList(), + backstack = TestBackstack(), ) presenter.test( CustomizeAddonState.Loading, @@ -147,6 +150,7 @@ class CustomizeTravelAddonPresenterTest { getAddonOfferUseCase = useCase, insuranceId = insuranceId, preselectedAddonDisplayNames = emptyList(), + backstack = TestBackstack(), ) presenter.test( CustomizeAddonState.Loading, @@ -264,7 +268,6 @@ private val fakeCommonSuccessParameters = CommonSuccessParameters( monthlyNet = UiMoney(100.0, UiCurrencyCode.SEK), discounts = emptyList(), ), - summaryParamsToNavigateFurther = null, notificationMessage = null, productVariant = fakeProductVariant, contractId = "test", diff --git a/app/feature/feature-addon-purchase/src/test/kotlin/ui/SelectInsuranceForAddonPresenterTest.kt b/app/feature/feature-addon-purchase/src/test/kotlin/ui/SelectInsuranceForAddonPresenterTest.kt index 7378660b69..49711c91da 100644 --- a/app/feature/feature-addon-purchase/src/test/kotlin/ui/SelectInsuranceForAddonPresenterTest.kt +++ b/app/feature/feature-addon-purchase/src/test/kotlin/ui/SelectInsuranceForAddonPresenterTest.kt @@ -12,6 +12,7 @@ import com.hedvig.android.core.common.ErrorMessage import com.hedvig.android.data.contract.ContractGroup import com.hedvig.android.feature.addon.purchase.data.GetInsuranceForTravelAddonUseCase import com.hedvig.android.feature.addon.purchase.data.InsuranceForAddon +import com.hedvig.android.feature.addon.purchase.navigation.CustomizeAddonKey import com.hedvig.android.feature.addon.purchase.ui.selectinsurance.SelectInsuranceForAddonEvent import com.hedvig.android.feature.addon.purchase.ui.selectinsurance.SelectInsuranceForAddonPresenter import com.hedvig.android.feature.addon.purchase.ui.selectinsurance.SelectInsuranceForAddonState @@ -38,6 +39,8 @@ class SelectInsuranceForAddonPresenterTest { val presenter = SelectInsuranceForAddonPresenter( getInsuranceForTravelAddonUseCase = useCase, ids = testIds, + preselectedAddonDisplayNames = emptyList(), + backstack = TestBackstack(), ) presenter.test(SelectInsuranceForAddonState.Loading) { skipItems(1) @@ -53,6 +56,8 @@ class SelectInsuranceForAddonPresenterTest { val presenter = SelectInsuranceForAddonPresenter( getInsuranceForTravelAddonUseCase = useCase, ids = emptyIds, + preselectedAddonDisplayNames = emptyList(), + backstack = TestBackstack(), ) presenter.test(SelectInsuranceForAddonState.Loading) { skipItems(1) @@ -63,21 +68,22 @@ class SelectInsuranceForAddonPresenterTest { @Test fun `if id list have only 1 item navigate further without loading anything`() = runTest { + val scheduler = testScheduler val useCase = FakeGetInsuranceForTravelAddonUseCase() + val backstack = TestBackstack() val presenter = SelectInsuranceForAddonPresenter( getInsuranceForTravelAddonUseCase = useCase, ids = listWithLonelyId, + preselectedAddonDisplayNames = emptyList(), + backstack = backstack, ) presenter.test(SelectInsuranceForAddonState.Loading) { skipItems(1) - val state = awaitItem() - assertThat(state).isEqualTo( - SelectInsuranceForAddonState.Success( - listOfInsurances = emptyList(), - insuranceIdToContinue = listWithLonelyId[0], - currentlySelected = null, - ), - ) + scheduler.advanceUntilIdle() + assertThat(backstack.entries.last()) + .isInstanceOf(CustomizeAddonKey::class) + .prop(CustomizeAddonKey::insuranceId).isEqualTo(listWithLonelyId[0]) + cancelAndIgnoreRemainingEvents() } } @@ -87,6 +93,8 @@ class SelectInsuranceForAddonPresenterTest { val presenter = SelectInsuranceForAddonPresenter( getInsuranceForTravelAddonUseCase = useCase, ids = testIds, + preselectedAddonDisplayNames = emptyList(), + backstack = TestBackstack(), ) presenter.test(SelectInsuranceForAddonState.Loading) { skipItems(1) @@ -103,6 +111,8 @@ class SelectInsuranceForAddonPresenterTest { val presenter = SelectInsuranceForAddonPresenter( getInsuranceForTravelAddonUseCase = useCase, ids = testIds, + preselectedAddonDisplayNames = emptyList(), + backstack = TestBackstack(), ) presenter.test(SelectInsuranceForAddonState.Loading) { skipItems(1) @@ -119,6 +129,8 @@ class SelectInsuranceForAddonPresenterTest { val presenter = SelectInsuranceForAddonPresenter( getInsuranceForTravelAddonUseCase = useCase, ids = testIds, + preselectedAddonDisplayNames = emptyList(), + backstack = TestBackstack(), ) presenter.test(SelectInsuranceForAddonState.Loading) { useCase.turbine.add(flowOf(listOfInsurances.right())) @@ -131,18 +143,25 @@ class SelectInsuranceForAddonPresenterTest { @Test fun `on continue navigate further with chosen insurance id`() = runTest { + val scheduler = testScheduler val useCase = FakeGetInsuranceForTravelAddonUseCase() + val backstack = TestBackstack() val presenter = SelectInsuranceForAddonPresenter( getInsuranceForTravelAddonUseCase = useCase, ids = testIds, + preselectedAddonDisplayNames = emptyList(), + backstack = backstack, ) presenter.test(SelectInsuranceForAddonState.Loading) { useCase.turbine.add(flowOf(listOfInsurances.right())) sendEvent(SelectInsuranceForAddonEvent.SelectInsurance(listOfInsurances[0])) skipItems(3) sendEvent(SelectInsuranceForAddonEvent.SubmitSelected(listOfInsurances[0])) - assertThat(awaitItem()).isInstanceOf(SelectInsuranceForAddonState.Success::class) - .prop(SelectInsuranceForAddonState.Success::insuranceIdToContinue).isEqualTo(listOfInsurances[0].id) + scheduler.advanceUntilIdle() + assertThat(backstack.entries.last()) + .isInstanceOf(CustomizeAddonKey::class) + .prop(CustomizeAddonKey::insuranceId).isEqualTo(listOfInsurances[0].id) + cancelAndIgnoreRemainingEvents() } } } diff --git a/app/feature/feature-addon-purchase/src/test/kotlin/ui/TestBackstack.kt b/app/feature/feature-addon-purchase/src/test/kotlin/ui/TestBackstack.kt new file mode 100644 index 0000000000..9c8c680c88 --- /dev/null +++ b/app/feature/feature-addon-purchase/src/test/kotlin/ui/TestBackstack.kt @@ -0,0 +1,8 @@ +package ui + +import com.hedvig.android.navigation.common.HedvigNavKey +import com.hedvig.android.navigation.compose.Backstack + +internal class TestBackstack( + override val entries: MutableList = mutableListOf(), +) : Backstack diff --git a/app/feature/feature-chip-id/src/main/kotlin/com/hedvig/android/feature/chip/id/navigation/ChipIdEntries.kt b/app/feature/feature-chip-id/src/main/kotlin/com/hedvig/android/feature/chip/id/navigation/ChipIdEntries.kt index 1fc07864d1..73df9e6ac6 100644 --- a/app/feature/feature-chip-id/src/main/kotlin/com/hedvig/android/feature/chip/id/navigation/ChipIdEntries.kt +++ b/app/feature/feature-chip-id/src/main/kotlin/com/hedvig/android/feature/chip/id/navigation/ChipIdEntries.kt @@ -9,7 +9,6 @@ import com.hedvig.android.feature.chip.id.ui.selectinsurance.SelectInsuranceForC import com.hedvig.android.feature.chip.id.ui.selectinsurance.SelectInsuranceForChipIdViewModel import com.hedvig.android.navigation.common.HedvigNavKey import com.hedvig.android.navigation.compose.Backstack -import com.hedvig.android.navigation.compose.add import com.hedvig.android.navigation.compose.findLastOrNull import com.hedvig.android.navigation.compose.navigateAndPopUpTo import com.hedvig.android.navigation.compose.popUpTo @@ -42,13 +41,6 @@ fun EntryProviderScope.chipIdEntries( viewModel = viewModel, navigateUp = navigateUp, popBackstack = popBackstackOrFinish, - navigateToAddChipId = { contractId: String, popSelectInsurance: Boolean -> - if (popSelectInsurance) { - backstack.navigateAndPopUpTo(AddChipIdKey(contractId), inclusive = true) - } else { - backstack.add(AddChipIdKey(contractId)) - } - }, ) } diff --git a/app/feature/feature-chip-id/src/main/kotlin/com/hedvig/android/feature/chip/id/ui/selectinsurance/SelectInsuranceForChipIdDestination.kt b/app/feature/feature-chip-id/src/main/kotlin/com/hedvig/android/feature/chip/id/ui/selectinsurance/SelectInsuranceForChipIdDestination.kt index 2a3b4020c8..1b5aa09e8f 100644 --- a/app/feature/feature-chip-id/src/main/kotlin/com/hedvig/android/feature/chip/id/ui/selectinsurance/SelectInsuranceForChipIdDestination.kt +++ b/app/feature/feature-chip-id/src/main/kotlin/com/hedvig/android/feature/chip/id/ui/selectinsurance/SelectInsuranceForChipIdDestination.kt @@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -71,7 +70,6 @@ internal fun SelectInsuranceForChipIdDestination( viewModel: SelectInsuranceForChipIdViewModel, navigateUp: () -> Unit, popBackstack: () -> Unit, - navigateToAddChipId: (contractId: String, popSelectInsurance: Boolean) -> Unit, ) { val uiState: SelectInsuranceForChipIdState by viewModel.uiState.collectAsStateWithLifecycle() @@ -79,9 +77,8 @@ internal fun SelectInsuranceForChipIdDestination( uiState = uiState, navigateUp = navigateUp, popBackstack = popBackstack, - navigateToAddChipId = { contractId, popSelectInsurance -> - navigateToAddChipId(contractId, popSelectInsurance) - viewModel.emit(SelectInsuranceForChipIdEvent.ClearNavigation) + submitSelected = { + viewModel.emit(SelectInsuranceForChipIdEvent.SubmitSelected) }, selectContract = { contract -> viewModel.emit(SelectInsuranceForChipIdEvent.SelectContract(contract)) @@ -99,7 +96,7 @@ private fun SelectInsuranceForChipIdScreen( popBackstack: () -> Unit, reload: () -> Unit, selectContract: (PetContractForChipId) -> Unit, - navigateToAddChipId: (contractId: String, popSelectInsurance: Boolean) -> Unit, + submitSelected: () -> Unit, ) { when (uiState) { SelectInsuranceForChipIdState.Failure -> { @@ -115,23 +112,13 @@ private fun SelectInsuranceForChipIdScreen( } is SelectInsuranceForChipIdState.Success -> { - LaunchedEffect(uiState.contractIdToContinue) { - if (uiState.contractIdToContinue != null) { - navigateToAddChipId( - uiState.contractIdToContinue, - uiState.contracts.size == 1, - ) - } - } - if (uiState.contractIdToContinue == null) { - SelectInsuranceForChipIdContentScreen( - uiState = uiState, - navigateUp = navigateUp, - popBackstack = popBackstack, - selectInsurance = selectContract, - navigateToAddChipId = navigateToAddChipId, - ) - } + SelectInsuranceForChipIdContentScreen( + uiState = uiState, + navigateUp = navigateUp, + popBackstack = popBackstack, + selectInsurance = selectContract, + submitSelected = submitSelected, + ) } } } @@ -142,7 +129,7 @@ private fun SelectInsuranceForChipIdContentScreen( navigateUp: () -> Unit, popBackstack: () -> Unit, selectInsurance: (selected: PetContractForChipId) -> Unit, - navigateToAddChipId: (contractId: String, popSelectInsurance: Boolean) -> Unit, + submitSelected: () -> Unit, ) { HedvigScaffold( navigateUp = navigateUp, @@ -210,11 +197,7 @@ private fun SelectInsuranceForChipIdContentScreen( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), - onClick = { - uiState.selectedContract?.let { - navigateToAddChipId(it.id, uiState.contracts.size == 1) - } - }, + onClick = submitSelected, isLoading = false, ) Spacer(Modifier.height(16.dp)) diff --git a/app/feature/feature-chip-id/src/main/kotlin/com/hedvig/android/feature/chip/id/ui/selectinsurance/SelectInsuranceForChipIdViewModel.kt b/app/feature/feature-chip-id/src/main/kotlin/com/hedvig/android/feature/chip/id/ui/selectinsurance/SelectInsuranceForChipIdViewModel.kt index a1550b97d7..f8fad8b12d 100644 --- a/app/feature/feature-chip-id/src/main/kotlin/com/hedvig/android/feature/chip/id/ui/selectinsurance/SelectInsuranceForChipIdViewModel.kt +++ b/app/feature/feature-chip-id/src/main/kotlin/com/hedvig/android/feature/chip/id/ui/selectinsurance/SelectInsuranceForChipIdViewModel.kt @@ -10,9 +10,14 @@ import androidx.compose.runtime.setValue import com.hedvig.android.core.common.di.AppScope import com.hedvig.android.feature.chip.id.data.GetContractsWithMissingChipIdUseCase import com.hedvig.android.feature.chip.id.data.PetContractForChipId +import com.hedvig.android.feature.chip.id.navigation.AddChipIdKey +import com.hedvig.android.feature.chip.id.navigation.ChipIdKey import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel +import com.hedvig.android.navigation.compose.Backstack +import com.hedvig.android.navigation.compose.add +import com.hedvig.android.navigation.compose.navigateAndPopUpTo import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject @@ -24,9 +29,14 @@ import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactoryKey internal class SelectInsuranceForChipIdViewModel( @Assisted preselectedContractId: String?, getContractsWithMissingChipIdUseCase: GetContractsWithMissingChipIdUseCase, + backstack: Backstack, ) : MoleculeViewModel( initialState = SelectInsuranceForChipIdState.Loading, - presenter = SelectInsuranceForChipIdPresenter(preselectedContractId, getContractsWithMissingChipIdUseCase), + presenter = SelectInsuranceForChipIdPresenter( + preselectedContractId, + getContractsWithMissingChipIdUseCase, + backstack, + ), ) { @AssistedFactory @ManualViewModelAssistedFactoryKey @@ -41,6 +51,7 @@ internal class SelectInsuranceForChipIdViewModel( internal class SelectInsuranceForChipIdPresenter( private val preselectedContractId: String?, private val getContractsWithMissingChipIdUseCase: GetContractsWithMissingChipIdUseCase, + private val backstack: Backstack, ) : MoleculePresenter { @Composable override fun MoleculePresenterScope.present( @@ -58,7 +69,6 @@ internal class SelectInsuranceForChipIdPresenter( }, ) } - var contractIdToContinue: String? by remember { mutableStateOf(null) } LaunchedEffect(loadIteration) { currentState = SelectInsuranceForChipIdState.Loading @@ -69,13 +79,12 @@ internal class SelectInsuranceForChipIdPresenter( val preselected = contracts.firstOrNull { it.id == preselectedContractId } if (contracts.size == 1) { - contractIdToContinue = contracts[0].id + navigateToAddChipId(contracts[0].id, popSelectInsurance = true) } SelectInsuranceForChipIdState.Success( contracts = contracts, selectedContract = preselected, - contractIdToContinue = contractIdToContinue, ) }, ) @@ -92,23 +101,17 @@ internal class SelectInsuranceForChipIdPresenter( } SelectInsuranceForChipIdEvent.SubmitSelected -> { + val successState = currentState as? SelectInsuranceForChipIdState.Success ?: return@CollectEvents selectedContract?.let { selected -> - contractIdToContinue = selected.id + navigateToAddChipId(selected.id, popSelectInsurance = successState.contracts.size == 1) } } - - SelectInsuranceForChipIdEvent.ClearNavigation -> { - contractIdToContinue = null - } } } return when (val state = currentState) { is SelectInsuranceForChipIdState.Success -> { - state.copy( - selectedContract = selectedContract ?: state.selectedContract, - contractIdToContinue = contractIdToContinue, - ) + state.copy(selectedContract = selectedContract ?: state.selectedContract) } else -> { @@ -116,6 +119,14 @@ internal class SelectInsuranceForChipIdPresenter( } } } + + private fun navigateToAddChipId(contractId: String, popSelectInsurance: Boolean) { + if (popSelectInsurance) { + backstack.navigateAndPopUpTo(AddChipIdKey(contractId), inclusive = true) + } else { + backstack.add(AddChipIdKey(contractId)) + } + } } internal sealed interface SelectInsuranceForChipIdState { @@ -124,7 +135,6 @@ internal sealed interface SelectInsuranceForChipIdState { data class Success( val contracts: List, val selectedContract: PetContractForChipId?, - val contractIdToContinue: String? = null, ) : SelectInsuranceForChipIdState data object Failure : SelectInsuranceForChipIdState @@ -136,6 +146,4 @@ internal sealed interface SelectInsuranceForChipIdEvent { data class SelectContract(val contract: PetContractForChipId) : SelectInsuranceForChipIdEvent data object SubmitSelected : SelectInsuranceForChipIdEvent - - data object ClearNavigation : SelectInsuranceForChipIdEvent } diff --git a/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/navigation/ChooseTierEntries.kt b/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/navigation/ChooseTierEntries.kt index 33c3900015..4458d77873 100644 --- a/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/navigation/ChooseTierEntries.kt +++ b/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/navigation/ChooseTierEntries.kt @@ -14,11 +14,8 @@ import com.hedvig.android.feature.change.tier.ui.stepsummary.SubmitTierSuccessSc import com.hedvig.android.feature.change.tier.ui.stepsummary.SummaryViewModel import com.hedvig.android.navigation.common.HedvigNavKey import com.hedvig.android.navigation.compose.Backstack -import com.hedvig.android.navigation.compose.add -import com.hedvig.android.navigation.compose.navigateAndPopUpTo import com.hedvig.android.navigation.compose.popBackstack import com.hedvig.android.navigation.compose.popUpTo -import com.hedvig.android.shared.tier.comparison.navigation.ComparisonParameters import com.hedvig.android.shared.tier.comparison.ui.ComparisonDestination import com.hedvig.android.shared.tier.comparison.ui.ComparisonViewModel import dev.zacsweers.metrox.viewmodel.assistedMetroViewModel @@ -36,9 +33,6 @@ fun EntryProviderScope.changeTierEntries(backstack: Backstack, onN popBackstack = { backstack.popBackstack() }, - launchFlow = { params: InsuranceCustomizationParameters -> - backstack.navigateAndPopUpTo(ChooseTierKey(params), inclusive = true) - }, onNavigateToNewConversation = dropUnlessResumed { onNavigateToNewConversation() }, navigateUp = backstack::navigateUp, ) @@ -49,12 +43,6 @@ fun EntryProviderScope.changeTierEntries(backstack: Backstack, onN ChooseInsuranceToChangeTierDestination( viewModel = viewModel, navigateUp = backstack::navigateUp, - navigateToNextStep = { params: InsuranceCustomizationParameters -> - backstack.navigateAndPopUpTo( - ChooseTierKey(params), - inclusive = true, - ) - }, popBackstack = { backstack.popBackstack() }, @@ -71,32 +59,9 @@ fun EntryProviderScope.changeTierEntries(backstack: Backstack, onN SelectTierDestination( viewModel = viewModel, navigateUp = backstack::navigateUp, - navigateToSummary = { quote -> - backstack.add( - SummaryKey( - SummaryParameters( - quoteIdToSubmit = quote.id, - activationDate = parameters.activationDate, - insuranceId = parameters.insuranceId, - ), - ), - ) - }, popBackstack = { backstack.popBackstack() }, - navigateToComparison = { listOfQuotes, selectedTerms -> - backstack.add( - ComparisonKey( - ComparisonParameters( - termsIds = listOfQuotes.map { - it.productVariant.termsVersion - }, - selectedTermsVersion = selectedTerms, - ), - ), - ) - }, ) } @@ -124,15 +89,6 @@ fun EntryProviderScope.changeTierEntries(backstack: Backstack, onN onExitTierFlow = { backstack.popUpTo(inclusive = true) }, - onFailure = { - backstack.add(SubmitFailureKey) - }, - onSuccess = { - backstack.navigateAndPopUpTo( - SubmitSuccessKey(key.params.activationDate), - inclusive = true, - ) - }, ) } diff --git a/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/ui/chooseinsurance/ChooseInsuranceToChangeTierDestination.kt b/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/ui/chooseinsurance/ChooseInsuranceToChangeTierDestination.kt index 8e13ac3fc6..b7b6b324b0 100644 --- a/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/ui/chooseinsurance/ChooseInsuranceToChangeTierDestination.kt +++ b/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/ui/chooseinsurance/ChooseInsuranceToChangeTierDestination.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.LineBreak @@ -28,7 +27,6 @@ import com.hedvig.android.design.system.hedvig.EmptyStateDefaults.EmptyStateIcon import com.hedvig.android.design.system.hedvig.HedvigButton import com.hedvig.android.design.system.hedvig.HedvigErrorSection import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedLinearProgress -import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgress import com.hedvig.android.design.system.hedvig.HedvigPreview import com.hedvig.android.design.system.hedvig.HedvigScaffold import com.hedvig.android.design.system.hedvig.HedvigText @@ -43,7 +41,6 @@ import com.hedvig.android.design.system.hedvig.Surface import com.hedvig.android.design.system.hedvig.icon.Close import com.hedvig.android.design.system.hedvig.icon.HedvigIcons import com.hedvig.android.feature.change.tier.data.CustomisableInsurance -import com.hedvig.android.feature.change.tier.navigation.InsuranceCustomizationParameters import com.hedvig.android.feature.change.tier.ui.stepstart.DeflectScreen import hedvig.resources.Res import hedvig.resources.TERMINATION_NO_TIER_QUOTES_SUBTITLE @@ -59,7 +56,6 @@ import org.jetbrains.compose.resources.stringResource internal fun ChooseInsuranceToChangeTierDestination( viewModel: ChooseInsuranceViewModel, navigateUp: () -> Unit, - navigateToNextStep: (params: InsuranceCustomizationParameters) -> Unit, onNavigateToNewConversation: () -> Unit, popBackstack: () -> Unit, ) { @@ -71,10 +67,6 @@ internal fun ChooseInsuranceToChangeTierDestination( reload = { viewModel.emit(ChooseInsuranceToCustomizeEvent.RetryLoadData) }, fetchTerminationStep = { viewModel.emit(ChooseInsuranceToCustomizeEvent.SubmitSelectedInsuranceToCustomize(it)) }, selectInsurance = { id -> viewModel.emit(ChooseInsuranceToCustomizeEvent.SelectInsurance(id)) }, - navigateToNextStep = { params -> - viewModel.emit(ChooseInsuranceToCustomizeEvent.ClearNavigationStep) - navigateToNextStep(params) - }, onNavigateToNewConversation = onNavigateToNewConversation, popBackstack = popBackstack, ) @@ -87,7 +79,6 @@ private fun ChooseInsuranceScreen( reload: () -> Unit, fetchTerminationStep: (insurance: CustomisableInsurance) -> Unit, selectInsurance: (insuranceId: String) -> Unit, - navigateToNextStep: (params: InsuranceCustomizationParameters) -> Unit, onNavigateToNewConversation: () -> Unit, popBackstack: () -> Unit, ) { @@ -123,13 +114,10 @@ private fun ChooseInsuranceScreen( } } - is ChooseInsuranceUiState.Loading -> { - LaunchedEffect(uiState.paramsToNavigateToNextStep) { - if (uiState.paramsToNavigateToNextStep != null) { - navigateToNextStep(uiState.paramsToNavigateToNextStep) - } - } - LoadingScreen(uiState) + ChooseInsuranceUiState.Loading -> { + HedvigFullScreenCenterAlignedLinearProgress( + title = stringResource(Res.string.TIER_FLOW_PROCESSING), + ) } is ChooseInsuranceUiState.Success -> { @@ -228,17 +216,6 @@ private fun ChooseInsuranceScreen( } } -@Composable -private fun LoadingScreen(uiState: ChooseInsuranceUiState.Loading) { - if (uiState.paramsToNavigateToNextStep == null) { - HedvigFullScreenCenterAlignedLinearProgress( - title = stringResource(Res.string.TIER_FLOW_PROCESSING), - ) - } else { - HedvigFullScreenCenterAlignedProgress() - } -} - @HedvigPreview @Composable private fun PreviewChooseInsuranceScreen( @@ -256,7 +233,6 @@ private fun PreviewChooseInsuranceScreen( {}, {}, {}, - {}, ) } } @@ -307,7 +283,7 @@ private class ChooseInsuranceUiStateProvider : changeTierIntentFailedToLoad = true, ), ChooseInsuranceUiState.Failure, - ChooseInsuranceUiState.Loading(), + ChooseInsuranceUiState.Loading, ChooseInsuranceUiState.NotAllowed, ChooseInsuranceUiState.Deflect( "How to change back to your previous coverage", diff --git a/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/ui/chooseinsurance/ChooseInsuranceViewModel.kt b/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/ui/chooseinsurance/ChooseInsuranceViewModel.kt index fb5f989824..531d909d27 100644 --- a/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/ui/chooseinsurance/ChooseInsuranceViewModel.kt +++ b/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/ui/chooseinsurance/ChooseInsuranceViewModel.kt @@ -13,7 +13,9 @@ import com.hedvig.android.data.changetier.data.ChangeTierCreateSource.SELF_SERVI import com.hedvig.android.data.changetier.data.ChangeTierRepository import com.hedvig.android.feature.change.tier.data.CustomisableInsurance import com.hedvig.android.feature.change.tier.data.GetCustomizableInsurancesUseCase +import com.hedvig.android.feature.change.tier.navigation.ChooseTierKey import com.hedvig.android.feature.change.tier.navigation.InsuranceCustomizationParameters +import com.hedvig.android.feature.change.tier.navigation.StartTierFlowChooseInsuranceKey import com.hedvig.android.feature.change.tier.ui.chooseinsurance.ChooseInsuranceUiState.Failure import com.hedvig.android.feature.change.tier.ui.chooseinsurance.ChooseInsuranceUiState.Loading import com.hedvig.android.feature.change.tier.ui.chooseinsurance.ChooseInsuranceUiState.NotAllowed @@ -22,6 +24,8 @@ import com.hedvig.android.logger.logcat import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel +import com.hedvig.android.navigation.compose.Backstack +import com.hedvig.android.navigation.compose.navigateAndPopUpTo import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject import dev.zacsweers.metro.binding @@ -33,17 +37,20 @@ import dev.zacsweers.metrox.viewmodel.ViewModelKey internal class ChooseInsuranceViewModel( getCustomizableInsurancesUseCase: GetCustomizableInsurancesUseCase, tierRepository: ChangeTierRepository, + backstack: Backstack, ) : MoleculeViewModel( - initialState = Loading(), + initialState = Loading, presenter = ChooseInsurancePresenter( getCustomizableInsurancesUseCase = getCustomizableInsurancesUseCase, tierRepository = tierRepository, + backstack = backstack, ), ) internal class ChooseInsurancePresenter( private val getCustomizableInsurancesUseCase: GetCustomizableInsurancesUseCase, private val tierRepository: ChangeTierRepository, + private val backstack: Backstack, ) : MoleculePresenter { @Composable override fun MoleculePresenterScope.present( @@ -80,21 +87,12 @@ internal class ChooseInsurancePresenter( ) } } - - ChooseInsuranceToCustomizeEvent.ClearNavigationStep -> { - val currentStateValue = currentState - if (currentStateValue is Loading) { - currentState = currentStateValue.copy( - paramsToNavigateToNextStep = null, - ) - } - } } } LaunchedEffect(insuranceToFetchIntentFor) { val customisableInsurance = insuranceToFetchIntentFor ?: return@LaunchedEffect - currentState = Loading() + currentState = Loading tierRepository .startChangeTierIntentAndGetQuotesId(customisableInsurance.id, SELF_SERVICE) .fold( @@ -122,7 +120,10 @@ internal class ChooseInsurancePresenter( quoteIds = intent.quotes.map { it.id }, ) insuranceToFetchIntentFor = null - currentState = Loading(params) + backstack.navigateAndPopUpTo( + ChooseTierKey(params), + inclusive = true, + ) } } }, @@ -131,7 +132,7 @@ internal class ChooseInsurancePresenter( LaunchedEffect(loadIteration) { if (lastState !is ChooseInsuranceUiState.Success) { - currentState = Loading() + currentState = Loading } getCustomizableInsurancesUseCase.invoke().collect { contractsResult -> contractsResult.fold( @@ -162,9 +163,7 @@ internal class ChooseInsurancePresenter( } internal sealed interface ChooseInsuranceUiState { - data class Loading( - val paramsToNavigateToNextStep: InsuranceCustomizationParameters? = null, - ) : ChooseInsuranceUiState + data object Loading : ChooseInsuranceUiState data class Success( val insuranceList: List, @@ -190,6 +189,4 @@ internal sealed interface ChooseInsuranceToCustomizeEvent { data class SubmitSelectedInsuranceToCustomize(val insurance: CustomisableInsurance) : ChooseInsuranceToCustomizeEvent - - data object ClearNavigationStep : ChooseInsuranceToCustomizeEvent } diff --git a/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/ui/stepcustomize/SelectCoverageViewModel.kt b/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/ui/stepcustomize/SelectCoverageViewModel.kt index e9c3ea912c..ddb0206ff3 100644 --- a/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/ui/stepcustomize/SelectCoverageViewModel.kt +++ b/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/ui/stepcustomize/SelectCoverageViewModel.kt @@ -14,13 +14,14 @@ import com.hedvig.android.data.changetier.data.Tier import com.hedvig.android.data.changetier.data.TierDeductibleQuote import com.hedvig.android.data.contract.ContractGroup import com.hedvig.android.feature.change.tier.data.GetCurrentContractDataUseCase +import com.hedvig.android.feature.change.tier.navigation.ComparisonKey import com.hedvig.android.feature.change.tier.navigation.InsuranceCustomizationParameters +import com.hedvig.android.feature.change.tier.navigation.SummaryKey +import com.hedvig.android.feature.change.tier.navigation.SummaryParameters import com.hedvig.android.feature.change.tier.ui.stepcustomize.SelectCoverageEvent.ChangeDeductibleForChosenTier import com.hedvig.android.feature.change.tier.ui.stepcustomize.SelectCoverageEvent.ChangeDeductibleInDialog import com.hedvig.android.feature.change.tier.ui.stepcustomize.SelectCoverageEvent.ChangeTier import com.hedvig.android.feature.change.tier.ui.stepcustomize.SelectCoverageEvent.ChangeTierInDialog -import com.hedvig.android.feature.change.tier.ui.stepcustomize.SelectCoverageEvent.ClearNavigateFurtherStep -import com.hedvig.android.feature.change.tier.ui.stepcustomize.SelectCoverageEvent.ClearNavigateToComparison import com.hedvig.android.feature.change.tier.ui.stepcustomize.SelectCoverageEvent.LaunchComparison import com.hedvig.android.feature.change.tier.ui.stepcustomize.SelectCoverageEvent.Reload import com.hedvig.android.feature.change.tier.ui.stepcustomize.SelectCoverageEvent.SetDeductibleToPreviouslyChosen @@ -33,6 +34,9 @@ import com.hedvig.android.logger.logcat import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel +import com.hedvig.android.navigation.compose.Backstack +import com.hedvig.android.navigation.compose.add +import com.hedvig.android.shared.tier.comparison.navigation.ComparisonParameters import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject @@ -45,12 +49,14 @@ internal class SelectCoverageViewModel( @Assisted params: InsuranceCustomizationParameters, tierRepository: ChangeTierRepository, getCurrentContractDataUseCase: GetCurrentContractDataUseCase, + backstack: Backstack, ) : MoleculeViewModel( initialState = Loading, presenter = SelectCoveragePresenter( params = params, getCurrentContractDataUseCase = getCurrentContractDataUseCase, tierRepository = tierRepository, + backstack = backstack, ), ) { @AssistedFactory @@ -67,6 +73,7 @@ internal class SelectCoveragePresenter( private val params: InsuranceCustomizationParameters, private val tierRepository: ChangeTierRepository, val getCurrentContractDataUseCase: GetCurrentContractDataUseCase, + private val backstack: Backstack, ) : MoleculePresenter { @Composable override fun MoleculePresenterScope.present( @@ -80,9 +87,6 @@ internal class SelectCoveragePresenter( var chosenQuoteInDialog by remember { mutableStateOf(if (lastState is Success) lastState.uiState.chosenQuote else null) } - var quoteToNavigateFurther by remember { mutableStateOf(null) } - var quotesToCompare by remember { mutableStateOf?>(null) } - var currentPartialState by remember { mutableStateOf(mapLastStateToPartial(state = lastState)) } var currentContractLoadIteration by remember { mutableIntStateOf(0) } @@ -103,15 +107,20 @@ internal class SelectCoveragePresenter( chosenQuoteInDialog = quoteToChoose } - ClearNavigateFurtherStep -> { - quoteToNavigateFurther = null - } - SubmitChosenQuoteToContinue -> { val state = currentPartialState if (state !is PartialUiState.Success) return@CollectEvents - if (chosenQuote != state.currentActiveQuote) { - quoteToNavigateFurther = chosenQuote + val quoteToContinue = chosenQuote + if (quoteToContinue != null && quoteToContinue != state.currentActiveQuote) { + backstack.add( + SummaryKey( + SummaryParameters( + quoteIdToSubmit = quoteToContinue.id, + activationDate = params.activationDate, + insuranceId = params.insuranceId, + ), + ), + ) } } @@ -123,8 +132,16 @@ internal class SelectCoveragePresenter( if (currentPartialState !is PartialUiState.Success) return@CollectEvents val notFiltered = (currentPartialState as PartialUiState.Success).map.values.flatten() val filtered = notFiltered.distinctBy { it.tier.tierName } - quotesToCompare = - filtered + backstack.add( + ComparisonKey( + ComparisonParameters( + termsIds = filtered.map { it.productVariant.termsVersion }, + selectedTermsVersion = filtered.firstOrNull { + it.tier.tierName == chosenTier?.tierName + }?.productVariant?.termsVersion, + ), + ), + ) } is ChangeDeductibleInDialog -> { @@ -142,10 +159,6 @@ internal class SelectCoveragePresenter( SetTierToPreviouslyChosen -> { chosenTierInDialog = chosenTier } - - ClearNavigateToComparison -> { - quotesToCompare = null - } } } @@ -218,8 +231,6 @@ internal class SelectCoveragePresenter( quotesForChosenTier = currentPartialStateValue.map[chosenTier]!!, isTierChoiceEnabled = currentPartialStateValue.map.keys.size > 1, contractData = currentPartialStateValue.contractData, - quoteToNavigateFurther = quoteToNavigateFurther, - quotesToCompare = quotesToCompare, chosenInDialogQuote = chosenQuoteInDialog, chosenInDialogTier = chosenTierInDialog, chosenTierIndex = chosenTierIndex, @@ -275,10 +286,6 @@ internal sealed interface SelectCoverageEvent { data object LaunchComparison : SelectCoverageEvent - data object ClearNavigateFurtherStep : SelectCoverageEvent - - data object ClearNavigateToComparison : SelectCoverageEvent - data object Reload : SelectCoverageEvent } @@ -330,8 +337,6 @@ internal data class SelectCoverageSuccessUiState( val chosenInDialogQuote: TierDeductibleQuote?, val isCurrentChosen: Boolean, val isTierChoiceEnabled: Boolean, - val quoteToNavigateFurther: TierDeductibleQuote? = null, - val quotesToCompare: List? = null, // sorted list of tiers with corresponding premiums (depending on selected deductible) val tiers: List>, val quotesForChosenTier: List, diff --git a/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/ui/stepcustomize/SelectTierDestination.kt b/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/ui/stepcustomize/SelectTierDestination.kt index b85b534539..b054469c73 100644 --- a/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/ui/stepcustomize/SelectTierDestination.kt +++ b/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/ui/stepcustomize/SelectTierDestination.kt @@ -21,7 +21,6 @@ import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -78,8 +77,6 @@ import com.hedvig.android.design.system.hedvig.a11y.getDescription import com.hedvig.android.design.system.hedvig.a11y.getPerMonthDescription import com.hedvig.android.design.system.hedvig.icon.Close import com.hedvig.android.design.system.hedvig.icon.HedvigIcons -import com.hedvig.android.feature.change.tier.ui.stepcustomize.SelectCoverageEvent.ClearNavigateFurtherStep -import com.hedvig.android.feature.change.tier.ui.stepcustomize.SelectCoverageEvent.ClearNavigateToComparison import com.hedvig.android.feature.change.tier.ui.stepcustomize.SelectCoverageState.Failure import com.hedvig.android.feature.change.tier.ui.stepcustomize.SelectCoverageState.Loading import com.hedvig.android.feature.change.tier.ui.stepcustomize.SelectCoverageState.Success @@ -112,8 +109,6 @@ internal fun SelectTierDestination( viewModel: SelectCoverageViewModel, navigateUp: () -> Unit, popBackstack: () -> Unit, - navigateToSummary: (quote: TierDeductibleQuote) -> Unit, - navigateToComparison: (listOfQuotes: List, selectedTermsVersion: String?) -> Unit, ) { val uiState: SelectCoverageState by viewModel.uiState.collectAsStateWithLifecycle() Box( @@ -134,23 +129,6 @@ internal fun SelectTierDestination( } is Success -> { - LaunchedEffect(state.uiState.quoteToNavigateFurther) { - if (state.uiState.quoteToNavigateFurther != null) { - viewModel.emit(ClearNavigateFurtherStep) - navigateToSummary(state.uiState.quoteToNavigateFurther) - } - } - LaunchedEffect(state.uiState.quotesToCompare) { - if (state.uiState.quotesToCompare != null) { - viewModel.emit(ClearNavigateToComparison) - navigateToComparison( - state.uiState.quotesToCompare, - state.uiState.quotesToCompare.firstOrNull { - it.tier.tierName == state.uiState.chosenTier?.tierName - }?.productVariant?.termsVersion, - ) - } - } SelectTierScreen( uiState = state.uiState, navigateUp = navigateUp, diff --git a/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/ui/stepstart/StartTierFlowDestination.kt b/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/ui/stepstart/StartTierFlowDestination.kt index 15ab9c5531..2e9598d6b7 100644 --- a/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/ui/stepstart/StartTierFlowDestination.kt +++ b/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/ui/stepstart/StartTierFlowDestination.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.PreviewParameter @@ -34,26 +33,22 @@ import com.hedvig.android.design.system.hedvig.HedvigTextButton import com.hedvig.android.design.system.hedvig.HedvigTheme import com.hedvig.android.design.system.hedvig.Surface import com.hedvig.android.design.system.hedvig.a11y.FlowHeading -import com.hedvig.android.feature.change.tier.navigation.InsuranceCustomizationParameters import com.hedvig.android.feature.change.tier.ui.stepstart.FailureReason.GENERAL import com.hedvig.android.feature.change.tier.ui.stepstart.FailureReason.QUOTES_ARE_EMPTY import com.hedvig.android.feature.change.tier.ui.stepstart.StartTierChangeState.Failure import com.hedvig.android.feature.change.tier.ui.stepstart.StartTierChangeState.Loading -import com.hedvig.android.feature.change.tier.ui.stepstart.StartTierChangeState.Success import hedvig.resources.DASHBOARD_OPEN_CHAT import hedvig.resources.Res import hedvig.resources.TERMINATION_FLOW_I_UNDERSTAND_TEXT import hedvig.resources.TERMINATION_NO_TIER_QUOTES_SUBTITLE import hedvig.resources.TIER_FLOW_PROCESSING import hedvig.resources.general_close_button -import kotlinx.datetime.LocalDate import org.jetbrains.compose.resources.stringResource @Composable internal fun StartChangeTierFlowDestination( viewModel: StartTierFlowViewModel, popBackstack: () -> Unit, - launchFlow: (InsuranceCustomizationParameters) -> Unit, onNavigateToNewConversation: () -> Unit, navigateUp: () -> Unit, ) { @@ -64,7 +59,6 @@ internal fun StartChangeTierFlowDestination( reload = { viewModel.emit(StartTierChangeEvent.Reload) }, - launchFlow = launchFlow, onNavigateToNewConversation = onNavigateToNewConversation, navigateUp = navigateUp, ) @@ -75,7 +69,6 @@ private fun StartChangeTierFlowScreen( uiState: StartTierChangeState, popBackstack: () -> Unit, reload: () -> Unit, - launchFlow: (InsuranceCustomizationParameters) -> Unit, onNavigateToNewConversation: () -> Unit, navigateUp: () -> Unit, ) { @@ -94,13 +87,6 @@ private fun StartChangeTierFlowScreen( ) } - is Success -> { - LaunchedEffect(uiState.paramsToNavigate) { - val params = uiState.paramsToNavigate - launchFlow(params) - } - } - is StartTierChangeState.Deflect -> { DeflectScreen( title = uiState.title, @@ -219,7 +205,6 @@ private fun StartTierFlowScreenPreview( {}, {}, {}, - {}, ) } } @@ -229,13 +214,6 @@ internal class StartTierChangeStateProvider : CollectionPreviewParameterProvider( listOf( Loading, - Success( - InsuranceCustomizationParameters( - "", - LocalDate(2024, 11, 11), - listOf("id", "id2"), - ), - ), Failure(GENERAL), Failure(QUOTES_ARE_EMPTY), StartTierChangeState.Deflect( diff --git a/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/ui/stepstart/StartTierFlowViewModel.kt b/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/ui/stepstart/StartTierFlowViewModel.kt index 6dac8191cc..d1b8645bff 100644 --- a/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/ui/stepstart/StartTierFlowViewModel.kt +++ b/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/ui/stepstart/StartTierFlowViewModel.kt @@ -11,18 +11,21 @@ import com.hedvig.android.core.common.ErrorMessage import com.hedvig.android.core.common.di.AppScope import com.hedvig.android.data.changetier.data.ChangeTierCreateSource import com.hedvig.android.data.changetier.data.ChangeTierRepository +import com.hedvig.android.feature.change.tier.navigation.ChooseTierKey import com.hedvig.android.feature.change.tier.navigation.InsuranceCustomizationParameters +import com.hedvig.android.feature.change.tier.navigation.StartTierFlowKey import com.hedvig.android.feature.change.tier.ui.stepstart.FailureReason.GENERAL import com.hedvig.android.feature.change.tier.ui.stepstart.FailureReason.QUOTES_ARE_EMPTY import com.hedvig.android.feature.change.tier.ui.stepstart.StartTierChangeEvent.Reload import com.hedvig.android.feature.change.tier.ui.stepstart.StartTierChangeState.Failure import com.hedvig.android.feature.change.tier.ui.stepstart.StartTierChangeState.Loading -import com.hedvig.android.feature.change.tier.ui.stepstart.StartTierChangeState.Success import com.hedvig.android.logger.LogPriority.WARN import com.hedvig.android.logger.logcat import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel +import com.hedvig.android.navigation.compose.Backstack +import com.hedvig.android.navigation.compose.navigateAndPopUpTo import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject @@ -34,11 +37,13 @@ import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactoryKey internal class StartTierFlowViewModel( @Assisted insuranceID: String, tierRepository: ChangeTierRepository, + backstack: Backstack, ) : MoleculeViewModel( initialState = Loading, presenter = StartTierChangePresenter( insuranceID = insuranceID, tierRepository = tierRepository, + backstack = backstack, ), ) { @AssistedFactory @@ -54,6 +59,7 @@ internal class StartTierFlowViewModel( internal class StartTierChangePresenter( private val insuranceID: String, private val tierRepository: ChangeTierRepository, + private val backstack: Backstack, ) : MoleculePresenter { @Composable override fun MoleculePresenterScope.present( @@ -87,7 +93,7 @@ internal class StartTierChangePresenter( activationDate = intent.activationDate, quoteIds = intent.quotes.map { it.id }, ) - currentState = Success(parameters) + backstack.navigateAndPopUpTo(ChooseTierKey(parameters), inclusive = true) } } }, @@ -106,10 +112,6 @@ internal class StartTierChangePresenter( internal sealed interface StartTierChangeState { data object Loading : StartTierChangeState - data class Success( - val paramsToNavigate: InsuranceCustomizationParameters, - ) : StartTierChangeState - data class Failure(val reason: FailureReason) : StartTierChangeState data class Deflect( diff --git a/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/ui/stepsummary/SummaryDestination.kt b/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/ui/stepsummary/SummaryDestination.kt index dabe9e6ff6..b690aefb46 100644 --- a/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/ui/stepsummary/SummaryDestination.kt +++ b/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/ui/stepsummary/SummaryDestination.kt @@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width 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 @@ -96,8 +95,6 @@ internal fun ChangeTierSummaryDestination( viewModel: SummaryViewModel, navigateUp: () -> Unit, onExitTierFlow: () -> Unit, - onSuccess: () -> Unit, - onFailure: () -> Unit, ) { val uiState: SummaryState by viewModel.uiState.collectAsStateWithLifecycle() SummaryScreen( @@ -105,14 +102,6 @@ internal fun ChangeTierSummaryDestination( onReload = { viewModel.emit(SummaryEvent.Reload) }, - onSuccess = { - viewModel.emit(SummaryEvent.ClearNavigation) - onSuccess() - }, - onFailure = { - viewModel.emit(SummaryEvent.ClearNavigation) - onFailure() - }, navigateUp = navigateUp, onSubmitQuoteClick = { viewModel.emit(SummaryEvent.SubmitQuote) @@ -125,9 +114,7 @@ internal fun ChangeTierSummaryDestination( private fun SummaryScreen( uiState: SummaryState, onReload: () -> Unit, - onSuccess: () -> Unit, navigateUp: () -> Unit, - onFailure: () -> Unit, onSubmitQuoteClick: () -> Unit, onExitTierFlow: () -> Unit, ) { @@ -147,23 +134,11 @@ private fun SummaryScreen( HedvigFullScreenCenterAlignedProgress() } - is MakingChanges -> { - LaunchedEffect(uiState.navigateToSuccess) { - val success = uiState.navigateToSuccess - if (success) { - onSuccess() - } - } + MakingChanges -> { MakingChangesScreen() } is Success -> { - LaunchedEffect(uiState.navigateToFail) { - if (uiState.navigateToFail) { - onFailure() - } - } - SummarySuccessScreen( uiState = uiState, navigateUp = navigateUp, @@ -399,8 +374,6 @@ private fun PreviewSummaryScreen( {}, {}, {}, - {}, - {}, ) } } @@ -478,6 +451,6 @@ private class SummaryUiStateProvider : ), Failure, Loading, - MakingChanges(false), + MakingChanges, ), ) diff --git a/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/ui/stepsummary/SummaryViewModel.kt b/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/ui/stepsummary/SummaryViewModel.kt index 41f17e3017..0af7b94ef8 100644 --- a/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/ui/stepsummary/SummaryViewModel.kt +++ b/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/ui/stepsummary/SummaryViewModel.kt @@ -12,9 +12,11 @@ import com.hedvig.android.core.uidata.UiMoney import com.hedvig.android.data.changetier.data.ChangeTierRepository import com.hedvig.android.data.changetier.data.TierDeductibleQuote import com.hedvig.android.feature.change.tier.data.GetCurrentContractDataUseCase +import com.hedvig.android.feature.change.tier.navigation.ChooseTierKey +import com.hedvig.android.feature.change.tier.navigation.SubmitFailureKey +import com.hedvig.android.feature.change.tier.navigation.SubmitSuccessKey import com.hedvig.android.feature.change.tier.navigation.SummaryParameters import com.hedvig.android.feature.change.tier.ui.stepcustomize.ContractData -import com.hedvig.android.feature.change.tier.ui.stepsummary.SummaryEvent.ClearNavigation import com.hedvig.android.feature.change.tier.ui.stepsummary.SummaryEvent.Reload import com.hedvig.android.feature.change.tier.ui.stepsummary.SummaryEvent.SubmitQuote import com.hedvig.android.feature.change.tier.ui.stepsummary.SummaryState.Failure @@ -26,6 +28,9 @@ import com.hedvig.android.logger.logcat import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel +import com.hedvig.android.navigation.compose.Backstack +import com.hedvig.android.navigation.compose.add +import com.hedvig.android.navigation.compose.navigateAndPopUpTo import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject @@ -39,12 +44,14 @@ internal class SummaryViewModel( @Assisted params: SummaryParameters, tierRepository: ChangeTierRepository, getCurrentContractDataUseCase: GetCurrentContractDataUseCase, + backstack: Backstack, ) : MoleculeViewModel( initialState = Loading, presenter = SummaryPresenter( params = params, tierRepository = tierRepository, getCurrentContractDataUseCase = getCurrentContractDataUseCase, + backstack = backstack, ), ) { @AssistedFactory @@ -61,6 +68,7 @@ private class SummaryPresenter( private val params: SummaryParameters, private val tierRepository: ChangeTierRepository, private val getCurrentContractDataUseCase: GetCurrentContractDataUseCase, + private val backstack: Backstack, ) : MoleculePresenter { @Composable override fun MoleculePresenterScope.present(lastState: SummaryState): SummaryState { @@ -77,35 +85,22 @@ private class SummaryPresenter( SubmitQuote -> { submitIteration++ } - - ClearNavigation -> { - if (currentState is MakingChanges) { - currentState = (currentState as MakingChanges).copy( - navigateToSuccess = false, - ) - } else if (currentState is Success) { - currentState = (currentState as Success).copy( - navigateToFail = false, - ) - } else { - return@CollectEvents - } - } } } LaunchedEffect(submitIteration) { if (submitIteration > 0) { val previousState = currentState - currentState = MakingChanges() + currentState = MakingChanges tierRepository.submitChangeTierQuote(params.quoteIdToSubmit).fold( ifLeft = { - currentState = - (previousState as Success).copy(navigateToFail = true) + currentState = previousState + backstack.add(SubmitFailureKey) }, ifRight = { - currentState = MakingChanges( - navigateToSuccess = true, + backstack.navigateAndPopUpTo( + SubmitSuccessKey(params.activationDate), + inclusive = true, ) }, ) @@ -159,15 +154,12 @@ private class SummaryPresenter( internal sealed interface SummaryState { data object Loading : SummaryState - data class MakingChanges( - val navigateToSuccess: Boolean = false, - ) : SummaryState + data object MakingChanges : SummaryState data class Success( val quote: TierDeductibleQuote, val currentContractData: ContractData, val activationDate: LocalDate, - val navigateToFail: Boolean = false, ) : SummaryState { val totalNet: UiMoney = quote.newTotalCost.monthlyNet } @@ -179,6 +171,4 @@ internal sealed interface SummaryEvent { data object SubmitQuote : SummaryEvent data object Reload : SummaryEvent - - data object ClearNavigation : SummaryEvent } diff --git a/app/feature/feature-choose-tier/src/test/kotlin/CommonTestdata.kt b/app/feature/feature-choose-tier/src/test/kotlin/CommonTestdata.kt index 9c497964ed..2aff04184e 100644 --- a/app/feature/feature-choose-tier/src/test/kotlin/CommonTestdata.kt +++ b/app/feature/feature-choose-tier/src/test/kotlin/CommonTestdata.kt @@ -16,8 +16,14 @@ import com.hedvig.android.data.changetier.data.TotalCost import com.hedvig.android.data.contract.ContractGroup import com.hedvig.android.data.contract.ContractType import com.hedvig.android.data.productvariant.ProductVariant +import com.hedvig.android.navigation.common.HedvigNavKey +import com.hedvig.android.navigation.compose.Backstack import com.hedvig.ui.tiersandaddons.CostBreakdownEntry +internal class TestBackstack( + override val entries: MutableList = mutableListOf(), +) : Backstack + internal class FakeChangeTierRepository() : ChangeTierRepository { val changeTierIntentTurbine = Turbine>() val quoteTurbine = Turbine>() diff --git a/app/feature/feature-choose-tier/src/test/kotlin/ui/chooseinsurance/ChooseInsurancePresenterTest.kt b/app/feature/feature-choose-tier/src/test/kotlin/ui/chooseinsurance/ChooseInsurancePresenterTest.kt index 36055bb5e9..64ed5d8963 100644 --- a/app/feature/feature-choose-tier/src/test/kotlin/ui/chooseinsurance/ChooseInsurancePresenterTest.kt +++ b/app/feature/feature-choose-tier/src/test/kotlin/ui/chooseinsurance/ChooseInsurancePresenterTest.kt @@ -1,6 +1,7 @@ package ui.chooseinsurance import FakeChangeTierRepository +import TestBackstack import app.cash.turbine.Turbine import arrow.core.Either import arrow.core.NonEmptyList @@ -10,7 +11,6 @@ import arrow.core.right import assertk.assertThat import assertk.assertions.isEqualTo import assertk.assertions.isInstanceOf -import assertk.assertions.isNotNull import assertk.assertions.isNull import assertk.assertions.prop import com.hedvig.android.core.common.ErrorMessage @@ -21,6 +21,7 @@ import com.hedvig.android.data.contract.ContractGroup.HOMEOWNER import com.hedvig.android.data.contract.ContractGroup.RENTAL import com.hedvig.android.feature.change.tier.data.CustomisableInsurance import com.hedvig.android.feature.change.tier.data.GetCustomizableInsurancesUseCase +import com.hedvig.android.feature.change.tier.navigation.ChooseTierKey import com.hedvig.android.feature.change.tier.ui.chooseinsurance.ChooseInsurancePresenter import com.hedvig.android.feature.change.tier.ui.chooseinsurance.ChooseInsuranceToCustomizeEvent import com.hedvig.android.feature.change.tier.ui.chooseinsurance.ChooseInsuranceUiState @@ -45,8 +46,9 @@ class ChooseInsurancePresenterTest { val presenter = ChooseInsurancePresenter( tierRepository = tierRepo, getCustomizableInsurancesUseCase = useCase, + backstack = TestBackstack(), ) - presenter.test(ChooseInsuranceUiState.Loading()) { + presenter.test(ChooseInsuranceUiState.Loading) { skipItems(1) useCase.turbine.add(flowOf(ErrorMessage().left())) val state = awaitItem() @@ -61,8 +63,9 @@ class ChooseInsurancePresenterTest { val presenter = ChooseInsurancePresenter( tierRepository = tierRepo, getCustomizableInsurancesUseCase = useCase, + backstack = TestBackstack(), ) - presenter.test(ChooseInsuranceUiState.Loading()) { + presenter.test(ChooseInsuranceUiState.Loading) { skipItems(1) useCase.turbine.add(flowOf(null.right())) val state = awaitItem() @@ -75,14 +78,15 @@ class ChooseInsurancePresenterTest { runTest { val tierRepo = FakeChangeTierRepository() val useCase = FakeGetCustomizableInsurancesUseCase() + val backstack = TestBackstack() + val scheduler = testScheduler val presenter = ChooseInsurancePresenter( tierRepository = tierRepo, getCustomizableInsurancesUseCase = useCase, + backstack = backstack, ) - presenter.test(ChooseInsuranceUiState.Loading()) { + presenter.test(ChooseInsuranceUiState.Loading) { assertThat(awaitItem()).isInstanceOf(ChooseInsuranceUiState.Loading::class) - .prop(ChooseInsuranceUiState.Loading::paramsToNavigateToNextStep) - .isNull() useCase.turbine.add( flowOf( nonEmptyListOf( @@ -104,9 +108,9 @@ class ChooseInsurancePresenterTest { null, ).right(), ) - assertThat(awaitItem()).isInstanceOf(ChooseInsuranceUiState.Loading::class) - .prop(ChooseInsuranceUiState.Loading::paramsToNavigateToNextStep) - .isNotNull() + scheduler.advanceUntilIdle() + assertThat(backstack.entries.last()).isInstanceOf(ChooseTierKey::class) + cancelAndIgnoreRemainingEvents() } } @@ -118,11 +122,10 @@ class ChooseInsurancePresenterTest { val presenter = ChooseInsurancePresenter( tierRepository = tierRepo, getCustomizableInsurancesUseCase = useCase, + backstack = TestBackstack(), ) - presenter.test(ChooseInsuranceUiState.Loading()) { + presenter.test(ChooseInsuranceUiState.Loading) { assertThat(awaitItem()).isInstanceOf(ChooseInsuranceUiState.Loading::class) - .prop(ChooseInsuranceUiState.Loading::paramsToNavigateToNextStep) - .isNull() useCase.turbine.add( flowOf( nonEmptyListOf( @@ -156,11 +159,10 @@ class ChooseInsurancePresenterTest { val presenter = ChooseInsurancePresenter( tierRepository = tierRepo, getCustomizableInsurancesUseCase = useCase, + backstack = TestBackstack(), ) - presenter.test(ChooseInsuranceUiState.Loading()) { + presenter.test(ChooseInsuranceUiState.Loading) { assertThat(awaitItem()).isInstanceOf(ChooseInsuranceUiState.Loading::class) - .prop(ChooseInsuranceUiState.Loading::paramsToNavigateToNextStep) - .isNull() useCase.turbine.add( flowOf( nonEmptyListOf( @@ -185,11 +187,10 @@ class ChooseInsurancePresenterTest { val presenter = ChooseInsurancePresenter( tierRepository = tierRepo, getCustomizableInsurancesUseCase = useCase, + backstack = TestBackstack(), ) - presenter.test(ChooseInsuranceUiState.Loading()) { + presenter.test(ChooseInsuranceUiState.Loading) { assertThat(awaitItem()).isInstanceOf(ChooseInsuranceUiState.Loading::class) - .prop(ChooseInsuranceUiState.Loading::paramsToNavigateToNextStep) - .isNull() useCase.turbine.add(flowOf(listOfInsurances.right())) assertThat(awaitItem()).isInstanceOf(ChooseInsuranceUiState.Success::class) .prop(ChooseInsuranceUiState.Success::insuranceList) @@ -204,11 +205,10 @@ class ChooseInsurancePresenterTest { val presenter = ChooseInsurancePresenter( tierRepository = tierRepo, getCustomizableInsurancesUseCase = useCase, + backstack = TestBackstack(), ) - presenter.test(ChooseInsuranceUiState.Loading()) { + presenter.test(ChooseInsuranceUiState.Loading) { assertThat(awaitItem()).isInstanceOf(ChooseInsuranceUiState.Loading::class) - .prop(ChooseInsuranceUiState.Loading::paramsToNavigateToNextStep) - .isNull() useCase.turbine.add(flowOf(listOfInsurances.right())) assertThat(awaitItem()).isInstanceOf(ChooseInsuranceUiState.Success::class) .prop(ChooseInsuranceUiState.Success::selectedInsurance) @@ -223,11 +223,10 @@ class ChooseInsurancePresenterTest { val presenter = ChooseInsurancePresenter( tierRepository = tierRepo, getCustomizableInsurancesUseCase = useCase, + backstack = TestBackstack(), ) - presenter.test(ChooseInsuranceUiState.Loading()) { + presenter.test(ChooseInsuranceUiState.Loading) { assertThat(awaitItem()).isInstanceOf(ChooseInsuranceUiState.Loading::class) - .prop(ChooseInsuranceUiState.Loading::paramsToNavigateToNextStep) - .isNull() useCase.turbine.add(flowOf(listOfInsurances.right())) skipItems(1) sendEvent(ChooseInsuranceToCustomizeEvent.SelectInsurance(listOfInsurances[0].id)) @@ -242,14 +241,15 @@ class ChooseInsurancePresenterTest { fun `when insurance is chosen on continue try to fetch intent and then navigate further if success`() = runTest { val tierRepo = FakeChangeTierRepository() val useCase = FakeGetCustomizableInsurancesUseCase() + val backstack = TestBackstack() + val scheduler = testScheduler val presenter = ChooseInsurancePresenter( tierRepository = tierRepo, getCustomizableInsurancesUseCase = useCase, + backstack = backstack, ) - presenter.test(ChooseInsuranceUiState.Loading()) { + presenter.test(ChooseInsuranceUiState.Loading) { assertThat(awaitItem()).isInstanceOf(ChooseInsuranceUiState.Loading::class) - .prop(ChooseInsuranceUiState.Loading::paramsToNavigateToNextStep) - .isNull() useCase.turbine.add(flowOf(listOfInsurances.right())) sendEvent(ChooseInsuranceToCustomizeEvent.SelectInsurance(listOfInsurances[0].id)) sendEvent(ChooseInsuranceToCustomizeEvent.SubmitSelectedInsuranceToCustomize(listOfInsurances[0])) @@ -263,9 +263,9 @@ class ChooseInsurancePresenterTest { null, ).right(), ) - assertThat(awaitItem()).isInstanceOf(ChooseInsuranceUiState.Loading::class) - .prop(ChooseInsuranceUiState.Loading::paramsToNavigateToNextStep) - .isNotNull() + scheduler.advanceUntilIdle() + assertThat(backstack.entries.last()).isInstanceOf(ChooseTierKey::class) + cancelAndIgnoreRemainingEvents() } } @@ -276,11 +276,10 @@ class ChooseInsurancePresenterTest { val presenter = ChooseInsurancePresenter( tierRepository = tierRepo, getCustomizableInsurancesUseCase = useCase, + backstack = TestBackstack(), ) - presenter.test(ChooseInsuranceUiState.Loading()) { + presenter.test(ChooseInsuranceUiState.Loading) { assertThat(awaitItem()).isInstanceOf(ChooseInsuranceUiState.Loading::class) - .prop(ChooseInsuranceUiState.Loading::paramsToNavigateToNextStep) - .isNull() useCase.turbine.add( flowOf( nonEmptyListOf( @@ -316,11 +315,10 @@ class ChooseInsurancePresenterTest { val presenter = ChooseInsurancePresenter( tierRepository = tierRepo, getCustomizableInsurancesUseCase = useCase, + backstack = TestBackstack(), ) - presenter.test(ChooseInsuranceUiState.Loading()) { + presenter.test(ChooseInsuranceUiState.Loading) { assertThat(awaitItem()).isInstanceOf(ChooseInsuranceUiState.Loading::class) - .prop(ChooseInsuranceUiState.Loading::paramsToNavigateToNextStep) - .isNull() useCase.turbine.add(flowOf(listOfInsurances.right())) sendEvent(ChooseInsuranceToCustomizeEvent.SelectInsurance(listOfInsurances[0].id)) sendEvent(ChooseInsuranceToCustomizeEvent.SubmitSelectedInsuranceToCustomize(listOfInsurances[0])) diff --git a/app/feature/feature-choose-tier/src/test/kotlin/ui/stepcustomize/SelectCoveragePresenterTest.kt b/app/feature/feature-choose-tier/src/test/kotlin/ui/stepcustomize/SelectCoveragePresenterTest.kt index 8c9b904084..922dad5b95 100644 --- a/app/feature/feature-choose-tier/src/test/kotlin/ui/stepcustomize/SelectCoveragePresenterTest.kt +++ b/app/feature/feature-choose-tier/src/test/kotlin/ui/stepcustomize/SelectCoveragePresenterTest.kt @@ -2,6 +2,7 @@ package ui.stepcustomize import CURRENT_ID import FakeChangeTierRepository +import TestBackstack import arrow.core.Either import arrow.core.raise.either import assertk.assertThat @@ -13,6 +14,7 @@ import basTier import com.hedvig.android.core.common.ErrorMessage import com.hedvig.android.feature.change.tier.data.CurrentContractData import com.hedvig.android.feature.change.tier.data.GetCurrentContractDataUseCase +import com.hedvig.android.feature.change.tier.navigation.ComparisonKey import com.hedvig.android.feature.change.tier.navigation.InsuranceCustomizationParameters import com.hedvig.android.feature.change.tier.ui.stepcustomize.SelectCoverageEvent import com.hedvig.android.feature.change.tier.ui.stepcustomize.SelectCoveragePresenter @@ -20,6 +22,7 @@ import com.hedvig.android.feature.change.tier.ui.stepcustomize.SelectCoverageSta import com.hedvig.android.feature.change.tier.ui.stepcustomize.SelectCoverageSuccessUiState import com.hedvig.android.logger.TestLogcatLoggingRule import com.hedvig.android.molecule.test.test +import com.hedvig.android.shared.tier.comparison.navigation.ComparisonParameters import currentQuote import kotlinx.coroutines.test.runTest import kotlinx.datetime.LocalDate @@ -42,6 +45,7 @@ class SelectCoveragePresenterTest { params = params, tierRepository = tierRepo, getCurrentContractDataUseCase = getCurrentContractDataUseCase, + backstack = TestBackstack(), ) presenter.test(SelectCoverageState.Loading) { tierRepo.quoteListTurbine.add(listOf()) @@ -58,6 +62,7 @@ class SelectCoveragePresenterTest { params = params, tierRepository = tierRepo, getCurrentContractDataUseCase = getCurrentContractDataUseCase, + backstack = TestBackstack(), ) presenter.test(SelectCoverageState.Loading) { tierRepo.quoteListTurbine.add(listOf(testQuote, testQuote2, currentQuote)) @@ -74,6 +79,7 @@ class SelectCoveragePresenterTest { params = params, tierRepository = tierRepo, getCurrentContractDataUseCase = getCurrentContractDataUseCase, + backstack = TestBackstack(), ) presenter.test(SelectCoverageState.Loading) { tierRepo.quoteListTurbine.add(listOf(testQuote, testQuote2, currentQuote)) @@ -100,6 +106,7 @@ class SelectCoveragePresenterTest { params = params, tierRepository = tierRepo, getCurrentContractDataUseCase = getCurrentContractDataUseCase, + backstack = TestBackstack(), ) presenter.test(SelectCoverageState.Loading) { tierRepo.quoteListTurbine.add(listOf(testQuote, testQuote2, testQuote3, currentQuote)) @@ -136,6 +143,7 @@ class SelectCoveragePresenterTest { params = params, tierRepository = tierRepo, getCurrentContractDataUseCase = getCurrentContractDataUseCase, + backstack = TestBackstack(), ) presenter.test(SelectCoverageState.Loading) { tierRepo.quoteListTurbine.add(listOf(testQuote, testQuote2, testQuote3, currentQuote)) @@ -164,10 +172,13 @@ class SelectCoveragePresenterTest { fun `when going to comparison one quote of each Tier is sent as parameter`() = runTest { val getCurrentContractDataUseCase = FakeGetCurrentContractDataUseCase() val tierRepo = FakeChangeTierRepository() + val backstack = TestBackstack() + val scheduler = testScheduler val presenter = SelectCoveragePresenter( params = params, tierRepository = tierRepo, getCurrentContractDataUseCase = getCurrentContractDataUseCase, + backstack = backstack, ) presenter.test(SelectCoverageState.Loading) { tierRepo.quoteListTurbine.add(listOf(testQuote, testQuote2, testQuote3, currentQuote)) @@ -175,16 +186,14 @@ class SelectCoveragePresenterTest { sendEvent( SelectCoverageEvent.LaunchComparison, ) - val state = awaitItem() - assertThat(state).isInstanceOf(SelectCoverageState.Success::class) - .prop(SelectCoverageState.Success::uiState) - .prop(SelectCoverageSuccessUiState::quotesToCompare) - .transform { - it?.map { quote -> - quote.tier.tierName - } - } - .isEqualTo(listOf("STANDARD", "BAS")) + scheduler.advanceUntilIdle() + assertThat(backstack.entries.last()) + .isInstanceOf(ComparisonKey::class) + .prop(ComparisonKey::comparisonParameters) + .prop(ComparisonParameters::termsIds) + .transform { it.size } + .isEqualTo(2) + cancelAndIgnoreRemainingEvents() } } @@ -196,6 +205,7 @@ class SelectCoveragePresenterTest { params = params, tierRepository = tierRepo, getCurrentContractDataUseCase = getCurrentContractDataUseCase, + backstack = TestBackstack(), ) presenter.test(SelectCoverageState.Loading) { tierRepo.quoteListTurbine.add(listOf(testQuote, testQuote2, currentQuote)) @@ -235,6 +245,7 @@ class SelectCoveragePresenterTest { params = params, tierRepository = tierRepo, getCurrentContractDataUseCase = getCurrentContractDataUseCase, + backstack = TestBackstack(), ) presenter.test(SelectCoverageState.Loading) { tierRepo.quoteListTurbine.add(listOf(testQuote, testQuote2, currentQuote)) diff --git a/app/feature/feature-choose-tier/src/test/kotlin/ui/stepstart/StartTierChangePresenterTest.kt b/app/feature/feature-choose-tier/src/test/kotlin/ui/stepstart/StartTierChangePresenterTest.kt index 04fabad4cc..4f9768b14a 100644 --- a/app/feature/feature-choose-tier/src/test/kotlin/ui/stepstart/StartTierChangePresenterTest.kt +++ b/app/feature/feature-choose-tier/src/test/kotlin/ui/stepstart/StartTierChangePresenterTest.kt @@ -1,16 +1,17 @@ package ui.stepstart import FakeChangeTierRepository +import TestBackstack import arrow.core.left import arrow.core.right import assertk.assertThat import assertk.assertions.isEqualTo import assertk.assertions.isInstanceOf -import assertk.assertions.isNotNull import assertk.assertions.prop import com.hedvig.android.data.changetier.data.ChangeTierDeductibleIntent import com.hedvig.android.data.changetier.data.DeflectOutput import com.hedvig.android.data.changetier.data.IntentOutput +import com.hedvig.android.feature.change.tier.navigation.ChooseTierKey import com.hedvig.android.feature.change.tier.ui.stepstart.FailureReason.GENERAL import com.hedvig.android.feature.change.tier.ui.stepstart.FailureReason.QUOTES_ARE_EMPTY import com.hedvig.android.feature.change.tier.ui.stepstart.StartTierChangePresenter @@ -36,6 +37,7 @@ class StartTierChangePresenterTest { val presenter = StartTierChangePresenter( tierRepository = tierRepo, insuranceID = insuranceId, + backstack = TestBackstack(), ) presenter.test(StartTierChangeState.Loading) { tierRepo.changeTierIntentTurbine.add( @@ -61,6 +63,7 @@ class StartTierChangePresenterTest { val presenter = StartTierChangePresenter( tierRepository = tierRepo, insuranceID = insuranceId, + backstack = TestBackstack(), ) presenter.test(StartTierChangeState.Loading) { tierRepo.changeTierIntentTurbine.add(com.hedvig.android.core.common.ErrorMessage().left()) @@ -75,9 +78,12 @@ class StartTierChangePresenterTest { @Test fun `if the quote list comes not empty redirect to select tier destination`() = runTest { val tierRepo = FakeChangeTierRepository() + val backstack = TestBackstack() + val scheduler = testScheduler val presenter = StartTierChangePresenter( tierRepository = tierRepo, insuranceID = insuranceId, + backstack = backstack, ) presenter.test(StartTierChangeState.Loading) { tierRepo.changeTierIntentTurbine.add( @@ -89,11 +95,9 @@ class StartTierChangePresenterTest { null, ).right(), ) - skipItems(1) - val state = awaitItem() - assertThat(state).isInstanceOf(StartTierChangeState.Success::class) - .prop(StartTierChangeState.Success::paramsToNavigate) - .isNotNull() + scheduler.advanceUntilIdle() + assertThat(backstack.entries.last()).isInstanceOf(ChooseTierKey::class) + cancelAndIgnoreRemainingEvents() } } @@ -103,6 +107,7 @@ class StartTierChangePresenterTest { val presenter = StartTierChangePresenter( tierRepository = tierRepo, insuranceID = insuranceId, + backstack = TestBackstack(), ) presenter.test(StartTierChangeState.Loading) { tierRepo.changeTierIntentTurbine.add( @@ -128,6 +133,7 @@ class StartTierChangePresenterTest { val presenter = StartTierChangePresenter( tierRepository = tierRepo, insuranceID = insuranceId, + backstack = TestBackstack(), ) presenter.test(StartTierChangeState.Loading) { tierRepo.changeTierIntentTurbine.add( diff --git a/app/feature/feature-edit-coinsured/src/main/kotlin/com/hedvig/android/feature/editcoinsured/navigation/EditCoInsuredDestinations.kt b/app/feature/feature-edit-coinsured/src/main/kotlin/com/hedvig/android/feature/editcoinsured/navigation/EditCoInsuredDestinations.kt index 7b0bd27458..a1f0cc8375 100644 --- a/app/feature/feature-edit-coinsured/src/main/kotlin/com/hedvig/android/feature/editcoinsured/navigation/EditCoInsuredDestinations.kt +++ b/app/feature/feature-edit-coinsured/src/main/kotlin/com/hedvig/android/feature/editcoinsured/navigation/EditCoInsuredDestinations.kt @@ -2,6 +2,9 @@ package com.hedvig.android.feature.editcoinsured.navigation import com.hedvig.android.data.coinsured.CoInsuredFlowType import com.hedvig.android.navigation.common.HedvigNavKey +import com.hedvig.android.navigation.compose.Backstack +import com.hedvig.android.navigation.compose.findLastOrNull +import com.hedvig.android.navigation.compose.navigateAndPopUpTo import kotlinx.datetime.LocalDate import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -33,3 +36,27 @@ internal data class EditCoOwnersTriageDeepLinkKey( @Serializable internal data class EditCoInsuredSuccessKey(val date: LocalDate, val type: CoInsuredFlowType) : HedvigNavKey + +/** + * The triage presenter serves both the normal [EditCoInsuredTriageKey] and the deep-link + * [EditCoOwnersTriageDeepLinkKey], so the pop anchor is resolved at navigation time. + */ +internal fun Backstack.navigateFromTriage(destination: HedvigNavKey) { + if (findLastOrNull() != null) { + navigateAndPopUpTo(destination, inclusive = true) + } else { + navigateAndPopUpTo(destination, inclusive = true) + } +} + +/** + * The edit presenter serves both [CoInsuredAddInfoKey] and [CoInsuredAddOrRemoveKey], so the pop + * anchor is resolved at navigation time. + */ +internal fun Backstack.navigateToEditCoInsuredSuccess(successKey: EditCoInsuredSuccessKey) { + if (findLastOrNull() != null) { + navigateAndPopUpTo(successKey, inclusive = true) + } else { + navigateAndPopUpTo(successKey, inclusive = true) + } +} diff --git a/app/feature/feature-edit-coinsured/src/main/kotlin/com/hedvig/android/feature/editcoinsured/navigation/EditCoInsuredEntries.kt b/app/feature/feature-edit-coinsured/src/main/kotlin/com/hedvig/android/feature/editcoinsured/navigation/EditCoInsuredEntries.kt index ac9a2bef82..97c6b1cc43 100644 --- a/app/feature/feature-edit-coinsured/src/main/kotlin/com/hedvig/android/feature/editcoinsured/navigation/EditCoInsuredEntries.kt +++ b/app/feature/feature-edit-coinsured/src/main/kotlin/com/hedvig/android/feature/editcoinsured/navigation/EditCoInsuredEntries.kt @@ -1,9 +1,7 @@ package com.hedvig.android.feature.editcoinsured.navigation import androidx.navigation3.runtime.EntryProviderScope -import com.hedvig.android.compose.ui.dropUnlessResumed import com.hedvig.android.data.coinsured.CoInsuredFlowType -import com.hedvig.android.feature.editcoinsured.data.InsuranceForEditOrAddCoInsured import com.hedvig.android.feature.editcoinsured.ui.EditCoInsuredAddMissingInfoDestination import com.hedvig.android.feature.editcoinsured.ui.EditCoInsuredAddOrRemoveDestination import com.hedvig.android.feature.editcoinsured.ui.EditCoInsuredSuccessDestination @@ -12,7 +10,6 @@ import com.hedvig.android.feature.editcoinsured.ui.triage.EditCoInsuredTriageDes import com.hedvig.android.feature.editcoinsured.ui.triage.EditCoInsuredTriageViewModel import com.hedvig.android.navigation.common.HedvigNavKey import com.hedvig.android.navigation.compose.Backstack -import com.hedvig.android.navigation.compose.navigateAndPopUpTo import com.hedvig.android.navigation.compose.popBackstack import dev.zacsweers.metrox.viewmodel.assistedMetroViewModel @@ -27,18 +24,6 @@ fun EntryProviderScope.editCoInsuredEntries(backstack: Backstack) EditCoInsuredTriageDestination( viewModel = viewModel, navigateUp = backstack::navigateUp, - navigateToAddMissingInfo = dropUnlessResumed { contract: InsuranceForEditOrAddCoInsured -> - backstack.navigateAndPopUpTo( - CoInsuredAddInfoKey(contract.id, contract.type), - inclusive = true, - ) - }, - navigateToAddOrRemoveCoInsured = dropUnlessResumed { contract: InsuranceForEditOrAddCoInsured -> - backstack.navigateAndPopUpTo( - CoInsuredAddOrRemoveKey(contract.id, contract.type), - inclusive = true, - ) - }, ) } @@ -51,18 +36,6 @@ fun EntryProviderScope.editCoInsuredEntries(backstack: Backstack) EditCoInsuredTriageDestination( viewModel = viewModel, navigateUp = backstack::navigateUp, - navigateToAddMissingInfo = dropUnlessResumed { contract: InsuranceForEditOrAddCoInsured -> - backstack.navigateAndPopUpTo( - CoInsuredAddInfoKey(contract.id, contract.type), - inclusive = true, - ) - }, - navigateToAddOrRemoveCoInsured = dropUnlessResumed { contract: InsuranceForEditOrAddCoInsured -> - backstack.navigateAndPopUpTo( - CoInsuredAddOrRemoveKey(contract.id, contract.type), - inclusive = true, - ) - }, ) } @@ -73,12 +46,6 @@ fun EntryProviderScope.editCoInsuredEntries(backstack: Backstack) viewModel = assistedMetroViewModel { create(addInfoContractId, addInfoType) }, - navigateToSuccessScreen = { - backstack.navigateAndPopUpTo( - EditCoInsuredSuccessKey(it, key.type), - inclusive = true, - ) - }, navigateUp = backstack::navigateUp, ) } @@ -89,12 +56,6 @@ fun EntryProviderScope.editCoInsuredEntries(backstack: Backstack) assistedMetroViewModel { create(addOrRemoveContractId, addOrRemoveType) }, - navigateToSuccessScreen = { - backstack.navigateAndPopUpTo( - EditCoInsuredSuccessKey(it, key.type), - inclusive = true, - ) - }, navigateUp = backstack::navigateUp, ) } diff --git a/app/feature/feature-edit-coinsured/src/main/kotlin/com/hedvig/android/feature/editcoinsured/ui/EditCoInsuredAddMissingInfoDestination.kt b/app/feature/feature-edit-coinsured/src/main/kotlin/com/hedvig/android/feature/editcoinsured/ui/EditCoInsuredAddMissingInfoDestination.kt index d9adb4c536..841ddd1cf9 100644 --- a/app/feature/feature-edit-coinsured/src/main/kotlin/com/hedvig/android/feature/editcoinsured/ui/EditCoInsuredAddMissingInfoDestination.kt +++ b/app/feature/feature-edit-coinsured/src/main/kotlin/com/hedvig/android/feature/editcoinsured/ui/EditCoInsuredAddMissingInfoDestination.kt @@ -56,11 +56,7 @@ import kotlinx.datetime.LocalDate import org.jetbrains.compose.resources.stringResource @Composable -internal fun EditCoInsuredAddMissingInfoDestination( - viewModel: EditCoInsuredViewModel, - navigateToSuccessScreen: (LocalDate) -> Unit, - navigateUp: () -> Unit, -) { +internal fun EditCoInsuredAddMissingInfoDestination(viewModel: EditCoInsuredViewModel, navigateUp: () -> Unit) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() EditCoInsuredScreen( @@ -78,9 +74,6 @@ internal fun EditCoInsuredAddMissingInfoDestination( onCommitChanges = { viewModel.emit(EditCoInsuredEvent.OnCommitChanges) }, - onCompleted = { - navigateToSuccessScreen(it) - }, onDismissError = { viewModel.emit(EditCoInsuredEvent.OnDismissError) }, @@ -116,7 +109,6 @@ private fun EditCoInsuredScreen( onSsnChanged: (String) -> Unit, onBottomSheetContinue: () -> Unit, onCommitChanges: () -> Unit, - onCompleted: (LocalDate) -> Unit, onDismissError: () -> Unit, onResetAddBottomSheetState: () -> Unit, onFirstNameChanged: (String) -> Unit, @@ -143,11 +135,6 @@ private fun EditCoInsuredScreen( } is EditCoInsuredState.Loaded -> { - LaunchedEffect(uiState.contractUpdateDate) { - if (uiState.contractUpdateDate != null) { - onCompleted(uiState.contractUpdateDate) - } - } val hedvigBottomSheetState = rememberHedvigBottomSheetState() DismissSheetOnSuccessfulInfoChangeEffect(hedvigBottomSheetState, uiState.finishedAdding) @@ -321,7 +308,6 @@ private fun EditCoInsuredScreenErrorPreview() { onSsnChanged = {}, onBottomSheetContinue = {}, onCommitChanges = {}, - onCompleted = {}, onDismissError = {}, onResetAddBottomSheetState = {}, onFirstNameChanged = {}, @@ -424,7 +410,6 @@ private fun EditCoInsuredScreenEditablePreview() { onSsnChanged = {}, onBottomSheetContinue = {}, onCommitChanges = {}, - onCompleted = {}, onDismissError = {}, onResetAddBottomSheetState = {}, onFirstNameChanged = {}, @@ -486,7 +471,6 @@ private fun EditCoInsuredScreenNonEditablePreview() { onSsnChanged = {}, onBottomSheetContinue = {}, onCommitChanges = {}, - onCompleted = {}, onDismissError = {}, onResetAddBottomSheetState = {}, onFirstNameChanged = {}, diff --git a/app/feature/feature-edit-coinsured/src/main/kotlin/com/hedvig/android/feature/editcoinsured/ui/EditCoInsuredAddOrRemoveDestination.kt b/app/feature/feature-edit-coinsured/src/main/kotlin/com/hedvig/android/feature/editcoinsured/ui/EditCoInsuredAddOrRemoveDestination.kt index 405757a3f5..c93cf4928c 100644 --- a/app/feature/feature-edit-coinsured/src/main/kotlin/com/hedvig/android/feature/editcoinsured/ui/EditCoInsuredAddOrRemoveDestination.kt +++ b/app/feature/feature-edit-coinsured/src/main/kotlin/com/hedvig/android/feature/editcoinsured/ui/EditCoInsuredAddOrRemoveDestination.kt @@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -75,11 +74,7 @@ import kotlinx.datetime.LocalDate import org.jetbrains.compose.resources.stringResource @Composable -internal fun EditCoInsuredAddOrRemoveDestination( - viewModel: EditCoInsuredViewModel, - navigateToSuccessScreen: (LocalDate) -> Unit, - navigateUp: () -> Unit, -) { +internal fun EditCoInsuredAddOrRemoveDestination(viewModel: EditCoInsuredViewModel, navigateUp: () -> Unit) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() EditCoInsuredScreen( @@ -103,9 +98,6 @@ internal fun EditCoInsuredAddOrRemoveDestination( onCommitChanges = { viewModel.emit(EditCoInsuredEvent.OnCommitChanges) }, - onCompleted = { - navigateToSuccessScreen(it) - }, onDismissError = { viewModel.emit(EditCoInsuredEvent.OnDismissError) }, @@ -146,7 +138,6 @@ private fun EditCoInsuredScreen( onRemoveCoInsuredClicked: (CoInsured) -> Unit, onAddCoInsuredClicked: () -> Unit, onCommitChanges: () -> Unit, - onCompleted: (LocalDate) -> Unit, onDismissError: () -> Unit, onResetAddBottomSheetState: () -> Unit, onResetRemoveBottomSheetState: () -> Unit, @@ -186,11 +177,6 @@ private fun EditCoInsuredScreen( .nestedScroll(remember { object : NestedScrollConnection {} }) .verticalScroll(state = rememberScrollState()), ) { - LaunchedEffect(uiState.contractUpdateDate) { - if (uiState.contractUpdateDate != null) { - onCompleted(uiState.contractUpdateDate) - } - } val addHedvigBottomSheetState = rememberHedvigBottomSheetState() DismissSheetOnSuccessfulInfoChangeEffect( @@ -421,7 +407,6 @@ private fun EditCoInsuredScreenErrorPreview() { onRemoveCoInsuredClicked = {}, onAddCoInsuredClicked = {}, onCommitChanges = {}, - onCompleted = {}, onDismissError = {}, onResetAddBottomSheetState = {}, onResetRemoveBottomSheetState = {}, @@ -512,7 +497,6 @@ private fun EditCoInsuredScreenEditablePreview() { onRemoveCoInsuredClicked = {}, onAddCoInsuredClicked = {}, onCommitChanges = {}, - onCompleted = {}, onDismissError = {}, onResetAddBottomSheetState = {}, onResetRemoveBottomSheetState = {}, @@ -577,7 +561,6 @@ private fun EditCoInsuredScreenNonEditablePreview() { onRemoveCoInsuredClicked = {}, onAddCoInsuredClicked = {}, onCommitChanges = {}, - onCompleted = {}, onDismissError = {}, onResetAddBottomSheetState = {}, onResetRemoveBottomSheetState = {}, diff --git a/app/feature/feature-edit-coinsured/src/main/kotlin/com/hedvig/android/feature/editcoinsured/ui/EditCoInsuredPresenter.kt b/app/feature/feature-edit-coinsured/src/main/kotlin/com/hedvig/android/feature/editcoinsured/ui/EditCoInsuredPresenter.kt index 294cf81730..9d0a8dce43 100644 --- a/app/feature/feature-edit-coinsured/src/main/kotlin/com/hedvig/android/feature/editcoinsured/ui/EditCoInsuredPresenter.kt +++ b/app/feature/feature-edit-coinsured/src/main/kotlin/com/hedvig/android/feature/editcoinsured/ui/EditCoInsuredPresenter.kt @@ -20,6 +20,8 @@ import com.hedvig.android.feature.editcoinsured.data.FetchCoInsuredPersonalInfor import com.hedvig.android.feature.editcoinsured.data.GetCoInsuredUseCase import com.hedvig.android.feature.editcoinsured.data.Member import com.hedvig.android.feature.editcoinsured.data.MonthlyCost +import com.hedvig.android.feature.editcoinsured.navigation.EditCoInsuredSuccessKey +import com.hedvig.android.feature.editcoinsured.navigation.navigateToEditCoInsuredSuccess import com.hedvig.android.feature.editcoinsured.ui.EditCoInsuredEvent.OnAddCoInsuredClicked import com.hedvig.android.feature.editcoinsured.ui.EditCoInsuredEvent.OnDismissError import com.hedvig.android.feature.editcoinsured.ui.EditCoInsuredEvent.OnRemoveCoInsuredClicked @@ -33,6 +35,7 @@ import com.hedvig.android.feature.editcoinsured.ui.EditCoInsuredState.Loaded.Man import com.hedvig.android.feature.editcoinsured.ui.EditCoInsuredState.Loading import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.navigation.compose.Backstack import kotlinx.datetime.LocalDate internal class EditCoInsuredPresenter( @@ -42,6 +45,7 @@ internal class EditCoInsuredPresenter( private val fetchCoInsuredPersonalInformationUseCase: FetchCoInsuredPersonalInformationUseCase, private val createMidtermChangeUseCase: CreateMidtermChangeUseCase, private val commitMidtermChangeUseCase: CommitMidtermChangeUseCase, + private val backstack: Backstack, ) : MoleculePresenter { @Composable override fun MoleculePresenterScope.present(lastState: EditCoInsuredState): EditCoInsuredState { @@ -72,7 +76,6 @@ internal class EditCoInsuredPresenter( var intentId by remember { mutableStateOf(null) } var selectedCoInsuredId by remember { mutableStateOf(null) } var commit by remember { mutableStateOf(false) } - var contractUpdateDate by remember { mutableStateOf(null) } var editedCoInsuredList by remember { mutableStateOf?>(null) } LaunchedEffect(Unit) { @@ -343,10 +346,8 @@ internal class EditCoInsuredPresenter( } }, ifRight = { - Snapshot.withMutableSnapshot { - listState = listState.copy(isCommittingUpdate = false) - contractUpdateDate = it.contractUpdateDate - } + listState = listState.copy(isCommittingUpdate = false) + backstack.navigateToEditCoInsuredSuccess(EditCoInsuredSuccessKey(it.contractUpdateDate, type)) }, ) } @@ -361,7 +362,6 @@ internal class EditCoInsuredPresenter( listState = listState, addBottomSheetContentState = addBottomSheetContentState, removeBottomSheetContentState = removeBottomSheetContentState, - contractUpdateDate = contractUpdateDate, finishedAdding = finishedAdding, finishedRemoving = finishedRemoving, ) @@ -478,7 +478,6 @@ internal sealed interface EditCoInsuredState { val listState: CoInsuredListState, val addBottomSheetContentState: AddBottomSheetContentState, val removeBottomSheetContentState: RemoveBottomSheetContentState, - val contractUpdateDate: LocalDate? = null, val finishedAdding: Boolean = false, val finishedRemoving: Boolean = false, ) : EditCoInsuredState { diff --git a/app/feature/feature-edit-coinsured/src/main/kotlin/com/hedvig/android/feature/editcoinsured/ui/EditCoInsuredViewModel.kt b/app/feature/feature-edit-coinsured/src/main/kotlin/com/hedvig/android/feature/editcoinsured/ui/EditCoInsuredViewModel.kt index c418df4a10..8562eb29d7 100644 --- a/app/feature/feature-edit-coinsured/src/main/kotlin/com/hedvig/android/feature/editcoinsured/ui/EditCoInsuredViewModel.kt +++ b/app/feature/feature-edit-coinsured/src/main/kotlin/com/hedvig/android/feature/editcoinsured/ui/EditCoInsuredViewModel.kt @@ -7,6 +7,7 @@ import com.hedvig.android.feature.editcoinsured.data.CreateMidtermChangeUseCase import com.hedvig.android.feature.editcoinsured.data.FetchCoInsuredPersonalInformationUseCase import com.hedvig.android.feature.editcoinsured.data.GetCoInsuredUseCase import com.hedvig.android.molecule.public.MoleculeViewModel +import com.hedvig.android.navigation.compose.Backstack import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject @@ -22,6 +23,7 @@ internal class EditCoInsuredViewModel( fetchCoInsuredPersonalInformationUseCaseProvider: FetchCoInsuredPersonalInformationUseCase, createMidtermChangeUseCase: CreateMidtermChangeUseCase, commitMidtermChangeUseCase: CommitMidtermChangeUseCase, + backstack: Backstack, ) : MoleculeViewModel( EditCoInsuredState.Loading, EditCoInsuredPresenter( @@ -31,6 +33,7 @@ internal class EditCoInsuredViewModel( fetchCoInsuredPersonalInformationUseCase = fetchCoInsuredPersonalInformationUseCaseProvider, createMidtermChangeUseCase = createMidtermChangeUseCase, commitMidtermChangeUseCase = commitMidtermChangeUseCase, + backstack = backstack, ), ) { @AssistedFactory diff --git a/app/feature/feature-edit-coinsured/src/main/kotlin/com/hedvig/android/feature/editcoinsured/ui/triage/EditCoInsuredTriageDestination.kt b/app/feature/feature-edit-coinsured/src/main/kotlin/com/hedvig/android/feature/editcoinsured/ui/triage/EditCoInsuredTriageDestination.kt index 687ecdd10d..779a941768 100644 --- a/app/feature/feature-edit-coinsured/src/main/kotlin/com/hedvig/android/feature/editcoinsured/ui/triage/EditCoInsuredTriageDestination.kt +++ b/app/feature/feature-edit-coinsured/src/main/kotlin/com/hedvig/android/feature/editcoinsured/ui/triage/EditCoInsuredTriageDestination.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.LineBreak @@ -43,12 +42,7 @@ import hedvig.resources.general_continue_button import org.jetbrains.compose.resources.stringResource @Composable -internal fun EditCoInsuredTriageDestination( - viewModel: EditCoInsuredTriageViewModel, - navigateUp: () -> Unit, - navigateToAddMissingInfo: (InsuranceForEditOrAddCoInsured) -> Unit, - navigateToAddOrRemoveCoInsured: (InsuranceForEditOrAddCoInsured) -> Unit, -) { +internal fun EditCoInsuredTriageDestination(viewModel: EditCoInsuredTriageViewModel, navigateUp: () -> Unit) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() EditCoInsuredTriageScreen( uiState = uiState, @@ -62,11 +56,6 @@ internal fun EditCoInsuredTriageDestination( selectInsurance = { viewModel.emit(EditCoInsuredTriageEvent.SelectInsurance(it)) }, - clearNavigation = { - viewModel.emit(EditCoInsuredTriageEvent.ClearNavigation) - }, - navigateToAddMissingInfo = navigateToAddMissingInfo, - navigateToAddOrRemoveCoInsured = navigateToAddOrRemoveCoInsured, ) } @@ -75,11 +64,8 @@ private fun EditCoInsuredTriageScreen( uiState: EditCoInsuredTriageUiState, navigateUp: () -> Unit, reload: () -> Unit, - navigateToAddMissingInfo: (InsuranceForEditOrAddCoInsured) -> Unit, - navigateToAddOrRemoveCoInsured: (InsuranceForEditOrAddCoInsured) -> Unit, submitSelectedInsurance: () -> Unit, selectInsurance: (id: String) -> Unit, - clearNavigation: () -> Unit, ) { when (uiState) { Failure -> { @@ -95,30 +81,12 @@ private fun EditCoInsuredTriageScreen( } is Success -> { - LaunchedEffect(uiState.insuranceToNavigateToAddOrRemoveCoInsured) { - if (uiState.insuranceToNavigateToAddOrRemoveCoInsured != null) { - clearNavigation() - navigateToAddOrRemoveCoInsured(uiState.insuranceToNavigateToAddOrRemoveCoInsured) - } - } - LaunchedEffect(uiState.insuranceToNavigateToAddMissingInfo) { - if (uiState.insuranceToNavigateToAddMissingInfo != null) { - clearNavigation() - navigateToAddMissingInfo(uiState.insuranceToNavigateToAddMissingInfo) - } - } - if (uiState.insuranceToNavigateToAddMissingInfo == null && - uiState.insuranceToNavigateToAddOrRemoveCoInsured == null - ) { - SuccessScreen( - uiState = uiState, - navigateUp = navigateUp, - submitSelectedInsurance = submitSelectedInsurance, - selectInsurance = selectInsurance, - ) - } else { - HedvigFullScreenCenterAlignedProgress() - } + SuccessScreen( + uiState = uiState, + navigateUp = navigateUp, + submitSelectedInsurance = submitSelectedInsurance, + selectInsurance = selectInsurance, + ) } } } @@ -221,16 +189,11 @@ private fun PreviewEditCoInsuredTriageScreen() { ), selected = null, type = CoInsuredFlowType.CoInsured, - null, - null, ), navigateUp = {}, reload = {}, - navigateToAddMissingInfo = {}, - navigateToAddOrRemoveCoInsured = {}, submitSelectedInsurance = {}, selectInsurance = {}, - clearNavigation = {}, ) } } diff --git a/app/feature/feature-edit-coinsured/src/main/kotlin/com/hedvig/android/feature/editcoinsured/ui/triage/EditCoInsuredTriageViewModel.kt b/app/feature/feature-edit-coinsured/src/main/kotlin/com/hedvig/android/feature/editcoinsured/ui/triage/EditCoInsuredTriageViewModel.kt index c4d06f532a..75e961dc59 100644 --- a/app/feature/feature-edit-coinsured/src/main/kotlin/com/hedvig/android/feature/editcoinsured/ui/triage/EditCoInsuredTriageViewModel.kt +++ b/app/feature/feature-edit-coinsured/src/main/kotlin/com/hedvig/android/feature/editcoinsured/ui/triage/EditCoInsuredTriageViewModel.kt @@ -12,6 +12,9 @@ import com.hedvig.android.data.coinsured.CoInsuredFlowType import com.hedvig.android.feature.editcoinsured.data.EditCoInsuredDestination import com.hedvig.android.feature.editcoinsured.data.GetInsurancesForEditCoInsuredUseCase import com.hedvig.android.feature.editcoinsured.data.InsuranceForEditOrAddCoInsured +import com.hedvig.android.feature.editcoinsured.navigation.CoInsuredAddInfoKey +import com.hedvig.android.feature.editcoinsured.navigation.CoInsuredAddOrRemoveKey +import com.hedvig.android.feature.editcoinsured.navigation.navigateFromTriage import com.hedvig.android.feature.editcoinsured.ui.triage.EditCoInsuredTriageEvent.OnContinueWithSelected import com.hedvig.android.feature.editcoinsured.ui.triage.EditCoInsuredTriageUiState.Failure import com.hedvig.android.feature.editcoinsured.ui.triage.EditCoInsuredTriageUiState.Loading @@ -19,6 +22,8 @@ import com.hedvig.android.feature.editcoinsured.ui.triage.EditCoInsuredTriageUiS import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel +import com.hedvig.android.navigation.common.HedvigNavKey +import com.hedvig.android.navigation.compose.Backstack import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject @@ -29,6 +34,7 @@ import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactoryKey @AssistedInject internal class EditCoInsuredTriageViewModel( getInsuranceForEditCoInsuredUseCase: GetInsurancesForEditCoInsuredUseCase, + backstack: Backstack, @Assisted insuranceId: String?, @Assisted type: CoInsuredFlowType, ) : MoleculeViewModel< @@ -36,7 +42,7 @@ internal class EditCoInsuredTriageViewModel( EditCoInsuredTriageUiState, >( initialState = Loading, - presenter = EditCoInsuredTriagePresenter(getInsuranceForEditCoInsuredUseCase, insuranceId, type), + presenter = EditCoInsuredTriagePresenter(getInsuranceForEditCoInsuredUseCase, backstack, insuranceId, type), ) { @AssistedFactory @ManualViewModelAssistedFactoryKey @@ -51,6 +57,7 @@ internal class EditCoInsuredTriageViewModel( internal class EditCoInsuredTriagePresenter( private val getInsuranceForEditCoInsuredUseCase: GetInsurancesForEditCoInsuredUseCase, + private val backstack: Backstack, private val insuranceId: String?, private val type: CoInsuredFlowType, ) : MoleculePresenter< @@ -69,32 +76,14 @@ internal class EditCoInsuredTriagePresenter( CollectEvents { event -> when (event) { is OnContinueWithSelected -> { - val currentStateValue = currentState as? Success ?: return@CollectEvents - selected?.let { - currentState = when (it.destination) { - EditCoInsuredDestination.MISSING_INFO -> currentStateValue.copy( - insuranceToNavigateToAddMissingInfo = it, - ) - - EditCoInsuredDestination.ADD_OR_REMOVE -> currentStateValue.copy( - insuranceToNavigateToAddOrRemoveCoInsured = it, - ) - } - } + currentState as? Success ?: return@CollectEvents + selected?.let { backstack.navigateFromTriage(it.toNavKey()) } } EditCoInsuredTriageEvent.Reload -> { loadIteration++ } - EditCoInsuredTriageEvent.ClearNavigation -> { - val currentStateValue = currentState as? Success ?: return@CollectEvents - currentState = currentStateValue.copy( - insuranceToNavigateToAddMissingInfo = null, - insuranceToNavigateToAddOrRemoveCoInsured = null, - ) - } - is EditCoInsuredTriageEvent.SelectInsurance -> { val currentStateValue = currentState as? Success ?: return@CollectEvents val selectedInsurance = currentStateValue.list.first { it.id == event.id } @@ -116,16 +105,17 @@ internal class EditCoInsuredTriagePresenter( } else { null } - val success = Success( - list = data, - selected = preselected, - type = type, - insuranceToNavigateToAddMissingInfo = - if (preselected?.destination == EditCoInsuredDestination.MISSING_INFO) preselected else null, - insuranceToNavigateToAddOrRemoveCoInsured = - if (preselected?.destination == EditCoInsuredDestination.ADD_OR_REMOVE) preselected else null, - ) - currentState = success + if (preselected != null) { + // Single match (deep link or only one insurance): skip the picker and navigate directly, + // popping the triage entry so back leaves the flow. + backstack.navigateFromTriage(preselected.toNavKey()) + } else { + currentState = Success( + list = data, + selected = null, + type = type, + ) + } }, ) } @@ -137,14 +127,17 @@ internal class EditCoInsuredTriagePresenter( } } +private fun InsuranceForEditOrAddCoInsured.toNavKey(): HedvigNavKey = when (destination) { + EditCoInsuredDestination.MISSING_INFO -> CoInsuredAddInfoKey(id, type) + EditCoInsuredDestination.ADD_OR_REMOVE -> CoInsuredAddOrRemoveKey(id, type) +} + internal sealed interface EditCoInsuredTriageEvent { data object OnContinueWithSelected : EditCoInsuredTriageEvent data object Reload : EditCoInsuredTriageEvent data class SelectInsurance(val id: String) : EditCoInsuredTriageEvent - - data object ClearNavigation : EditCoInsuredTriageEvent } internal sealed interface EditCoInsuredTriageUiState { @@ -156,7 +149,5 @@ internal sealed interface EditCoInsuredTriageUiState { val list: List, val selected: InsuranceForEditOrAddCoInsured?, val type: CoInsuredFlowType, - val insuranceToNavigateToAddMissingInfo: InsuranceForEditOrAddCoInsured? = null, - val insuranceToNavigateToAddOrRemoveCoInsured: InsuranceForEditOrAddCoInsured? = null, ) : EditCoInsuredTriageUiState } diff --git a/app/feature/feature-edit-coinsured/src/test/kotlin/com/hedvig/android/feature/editcoinsured/ui/EditCoInsuredPresenterTest.kt b/app/feature/feature-edit-coinsured/src/test/kotlin/com/hedvig/android/feature/editcoinsured/ui/EditCoInsuredPresenterTest.kt index 9ee0a33be0..9d426639c9 100644 --- a/app/feature/feature-edit-coinsured/src/test/kotlin/com/hedvig/android/feature/editcoinsured/ui/EditCoInsuredPresenterTest.kt +++ b/app/feature/feature-edit-coinsured/src/test/kotlin/com/hedvig/android/feature/editcoinsured/ui/EditCoInsuredPresenterTest.kt @@ -37,6 +37,7 @@ internal class EditCoInsuredPresenterTest { fetchCoInsuredPersonalInformationUseCase = testFetchCoInsuredPersonalInformationUseCase, createMidtermChangeUseCase = testCreateMidTermChangeUseCase, commitMidtermChangeUseCase = testCommitMidtermChangeUseCase, + backstack = TestBackstack(), ) presenter.test(EditCoInsuredState.Loading) { @@ -73,6 +74,7 @@ internal class EditCoInsuredPresenterTest { fetchCoInsuredPersonalInformationUseCase = testFetchCoInsuredPersonalInformationUseCase, createMidtermChangeUseCase = testCreateMidTermChangeUseCase, commitMidtermChangeUseCase = testCommitMidtermChangeUseCase, + backstack = TestBackstack(), ) presenter.test( @@ -119,6 +121,7 @@ internal class EditCoInsuredPresenterTest { fetchCoInsuredPersonalInformationUseCase = testFetchCoInsuredPersonalInformationUseCase, createMidtermChangeUseCase = testCreateMidTermChangeUseCase, commitMidtermChangeUseCase = testCommitMidtermChangeUseCase, + backstack = TestBackstack(), ) presenter.test( diff --git a/app/feature/feature-edit-coinsured/src/test/kotlin/com/hedvig/android/feature/editcoinsured/ui/TestBackstack.kt b/app/feature/feature-edit-coinsured/src/test/kotlin/com/hedvig/android/feature/editcoinsured/ui/TestBackstack.kt new file mode 100644 index 0000000000..1617f6fa87 --- /dev/null +++ b/app/feature/feature-edit-coinsured/src/test/kotlin/com/hedvig/android/feature/editcoinsured/ui/TestBackstack.kt @@ -0,0 +1,8 @@ +package com.hedvig.android.feature.editcoinsured.ui + +import com.hedvig.android.navigation.common.HedvigNavKey +import com.hedvig.android.navigation.compose.Backstack + +internal class TestBackstack( + override val entries: MutableList = mutableListOf(), +) : Backstack diff --git a/app/feature/feature-help-center/src/commonMain/kotlin/com/hedvig/android/feature/help/center/HelpCenterEntries.kt b/app/feature/feature-help-center/src/commonMain/kotlin/com/hedvig/android/feature/help/center/HelpCenterEntries.kt index a29f7608e9..0e7708e7a7 100644 --- a/app/feature/feature-help-center/src/commonMain/kotlin/com/hedvig/android/feature/help/center/HelpCenterEntries.kt +++ b/app/feature/feature-help-center/src/commonMain/kotlin/com/hedvig/android/feature/help/center/HelpCenterEntries.kt @@ -6,9 +6,6 @@ import coil3.ImageLoader import com.hedvig.android.compose.ui.dropUnlessResumed import com.hedvig.android.feature.help.center.commonclaim.FirstVetDestination import com.hedvig.android.feature.help.center.commonclaim.emergency.EmergencyDestination -import com.hedvig.android.feature.help.center.data.InnerHelpCenterDestination -import com.hedvig.android.feature.help.center.data.InnerHelpCenterDestination.FirstVet -import com.hedvig.android.feature.help.center.data.InnerHelpCenterDestination.QuickLinkSickAbroad import com.hedvig.android.feature.help.center.data.QuickLinkDestination import com.hedvig.android.feature.help.center.home.HelpCenterHomeDestination import com.hedvig.android.feature.help.center.navigation.EmergencyKey @@ -54,23 +51,7 @@ fun EntryProviderScope.helpCenterEntries( navigateToQuestion(question, backstack) }, onNavigateToQuickLink = dropUnlessResumed { destination -> - when (destination) { - is QuickLinkDestination.OuterDestination -> { - onNavigateToQuickLink(destination) - } - - is InnerHelpCenterDestination -> { - when (destination) { - is FirstVet -> { - backstack.add(FirstVetKey(destination.sections)) - } - - is QuickLinkSickAbroad -> { - backstack.add(EmergencyKey(destination.deflectData)) - } - } - } - } + onNavigateToQuickLink(destination) }, onNavigateToInbox = dropUnlessResumed { onNavigateToInbox() diff --git a/app/feature/feature-help-center/src/commonMain/kotlin/com/hedvig/android/feature/help/center/HelpCenterPresenter.kt b/app/feature/feature-help-center/src/commonMain/kotlin/com/hedvig/android/feature/help/center/HelpCenterPresenter.kt index 76d7ac8c5d..f6cde0670b 100644 --- a/app/feature/feature-help-center/src/commonMain/kotlin/com/hedvig/android/feature/help/center/HelpCenterPresenter.kt +++ b/app/feature/feature-help-center/src/commonMain/kotlin/com/hedvig/android/feature/help/center/HelpCenterPresenter.kt @@ -26,10 +26,15 @@ import com.hedvig.android.feature.help.center.data.FAQTopic import com.hedvig.android.feature.help.center.data.GetHelpCenterFAQUseCase import com.hedvig.android.feature.help.center.data.GetPuppyGuideUseCase import com.hedvig.android.feature.help.center.data.GetQuickLinksUseCase +import com.hedvig.android.feature.help.center.data.InnerHelpCenterDestination import com.hedvig.android.feature.help.center.data.QuickLinkDestination import com.hedvig.android.feature.help.center.model.QuickAction +import com.hedvig.android.feature.help.center.navigation.EmergencyKey +import com.hedvig.android.feature.help.center.navigation.FirstVetKey import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.navigation.compose.Backstack +import com.hedvig.android.navigation.compose.add import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flow @@ -61,7 +66,7 @@ internal data class HelpCenterUiState( val selectedQuickAction: QuickAction?, val search: Search?, val showNavigateToInboxButton: Boolean, - val destinationToNavigate: QuickLinkDestination? = null, + val destinationToNavigate: QuickLinkDestination.OuterDestination? = null, val puppyGuide: PuppyGuidePresentation?, ) { data class QuickLink(val quickAction: QuickAction) @@ -102,6 +107,7 @@ internal class HelpCenterPresenter( private val hasAnyActiveConversationUseCase: HasAnyActiveConversationUseCase, private val getHelpCenterFAQUseCase: GetHelpCenterFAQUseCase, private val getPuppyGuideUseCase: GetPuppyGuideUseCase, + private val backstack: Backstack, ) : MoleculePresenter { @Composable override fun MoleculePresenterScope.present(lastState: HelpCenterUiState): HelpCenterUiState { @@ -154,7 +160,19 @@ internal class HelpCenterPresenter( is NavigateToQuickAction -> { selectedQuickAction = null - currentState = currentState.copy(destinationToNavigate = event.destination) + when (val destination = event.destination) { + is InnerHelpCenterDestination.FirstVet -> { + backstack.add(FirstVetKey(destination.sections)) + } + + is InnerHelpCenterDestination.QuickLinkSickAbroad -> { + backstack.add(EmergencyKey(destination.deflectData)) + } + + is QuickLinkDestination.OuterDestination -> { + currentState = currentState.copy(destinationToNavigate = destination) + } + } } HelpCenterEvent.ReloadFAQAndQuickLinks -> { diff --git a/app/feature/feature-help-center/src/commonMain/kotlin/com/hedvig/android/feature/help/center/HelpCenterViewModel.kt b/app/feature/feature-help-center/src/commonMain/kotlin/com/hedvig/android/feature/help/center/HelpCenterViewModel.kt index ba95c7b237..1f5ba69a5b 100644 --- a/app/feature/feature-help-center/src/commonMain/kotlin/com/hedvig/android/feature/help/center/HelpCenterViewModel.kt +++ b/app/feature/feature-help-center/src/commonMain/kotlin/com/hedvig/android/feature/help/center/HelpCenterViewModel.kt @@ -7,6 +7,7 @@ import com.hedvig.android.feature.help.center.data.GetHelpCenterFAQUseCase import com.hedvig.android.feature.help.center.data.GetPuppyGuideUseCase import com.hedvig.android.feature.help.center.data.GetQuickLinksUseCase import com.hedvig.android.molecule.public.MoleculeViewModel +import com.hedvig.android.navigation.compose.Backstack import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject import dev.zacsweers.metro.binding @@ -20,6 +21,7 @@ internal class HelpCenterViewModel( hasAnyActiveConversationUseCase: HasAnyActiveConversationUseCase, getHelpCenterFAQUseCase: GetHelpCenterFAQUseCase, getPuppyGuideUseCase: GetPuppyGuideUseCase, + backstack: Backstack, ) : MoleculeViewModel( initialState = HelpCenterUiState( topics = listOf(), @@ -35,5 +37,6 @@ internal class HelpCenterViewModel( hasAnyActiveConversationUseCase = hasAnyActiveConversationUseCase, getHelpCenterFAQUseCase = getHelpCenterFAQUseCase, getPuppyGuideUseCase = getPuppyGuideUseCase, + backstack = backstack, ), ) diff --git a/app/feature/feature-help-center/src/commonMain/kotlin/com/hedvig/android/feature/help/center/home/HelpCenterHomeDestination.kt b/app/feature/feature-help-center/src/commonMain/kotlin/com/hedvig/android/feature/help/center/home/HelpCenterHomeDestination.kt index 95bcb53329..0bd5504f13 100644 --- a/app/feature/feature-help-center/src/commonMain/kotlin/com/hedvig/android/feature/help/center/home/HelpCenterHomeDestination.kt +++ b/app/feature/feature-help-center/src/commonMain/kotlin/com/hedvig/android/feature/help/center/home/HelpCenterHomeDestination.kt @@ -134,7 +134,7 @@ internal fun HelpCenterHomeDestination( viewModel: HelpCenterViewModel, onNavigateToTopic: (topicId: String) -> Unit, onNavigateToQuestion: (questionId: String) -> Unit, - onNavigateToQuickLink: (QuickLinkDestination) -> Unit, + onNavigateToQuickLink: (QuickLinkDestination.OuterDestination) -> Unit, onNavigateUp: () -> Unit, onNavigateToInbox: () -> Unit, onNavigateToNewConversation: () -> Unit, diff --git a/app/feature/feature-insurance-certificate/src/main/kotlin/com/hedvig/android/feature/insurance/certificate/navigation/InsuranceEvidenceEntries.kt b/app/feature/feature-insurance-certificate/src/main/kotlin/com/hedvig/android/feature/insurance/certificate/navigation/InsuranceEvidenceEntries.kt index 27f1247000..31174b69c8 100644 --- a/app/feature/feature-insurance-certificate/src/main/kotlin/com/hedvig/android/feature/insurance/certificate/navigation/InsuranceEvidenceEntries.kt +++ b/app/feature/feature-insurance-certificate/src/main/kotlin/com/hedvig/android/feature/insurance/certificate/navigation/InsuranceEvidenceEntries.kt @@ -8,7 +8,6 @@ import com.hedvig.android.feature.insurance.certificate.ui.overview.InsuranceEvi import com.hedvig.android.feature.insurance.certificate.ui.overview.InsuranceEvidenceOverviewViewModel import com.hedvig.android.navigation.common.HedvigNavKey import com.hedvig.android.navigation.compose.Backstack -import com.hedvig.android.navigation.compose.navigateAndPopUpTo import com.hedvig.core.common.android.sharePDF import dev.zacsweers.metrox.viewmodel.metroViewModel @@ -18,12 +17,6 @@ fun EntryProviderScope.insuranceEvidenceEntries(backstack: Backsta InsuranceEvidenceEmailInputDestination( viewModel = viewModel, navigateUp = backstack::navigateUp, - navigateToShowCertificate = { url -> - backstack.navigateAndPopUpTo( - ShowCertificateKey(url), - inclusive = true, - ) - }, ) } entry { key -> diff --git a/app/feature/feature-insurance-certificate/src/main/kotlin/com/hedvig/android/feature/insurance/certificate/ui/email/InsuranceEvidenceEmailInputDestination.kt b/app/feature/feature-insurance-certificate/src/main/kotlin/com/hedvig/android/feature/insurance/certificate/ui/email/InsuranceEvidenceEmailInputDestination.kt index 436880fd7d..ff07bb3fd9 100644 --- a/app/feature/feature-insurance-certificate/src/main/kotlin/com/hedvig/android/feature/insurance/certificate/ui/email/InsuranceEvidenceEmailInputDestination.kt +++ b/app/feature/feature-insurance-certificate/src/main/kotlin/com/hedvig/android/feature/insurance/certificate/ui/email/InsuranceEvidenceEmailInputDestination.kt @@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll 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 @@ -68,13 +67,11 @@ import org.jetbrains.compose.resources.stringResource @Composable internal fun InsuranceEvidenceEmailInputDestination( viewModel: InsuranceEvidenceEmailInputViewModel, - navigateToShowCertificate: (url: String) -> Unit, navigateUp: () -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() InsuranceEvidenceEmailInputScreen( uiState, - navigateToShowCertificate = navigateToShowCertificate, navigateUp = navigateUp, onRetry = { viewModel.emit(InsuranceEvidenceEmailInputEvent.RetryLoadData) @@ -85,9 +82,6 @@ internal fun InsuranceEvidenceEmailInputDestination( onSubmit = { viewModel.emit(InsuranceEvidenceEmailInputEvent.Submit) }, - onClearNavigation = { - viewModel.emit(InsuranceEvidenceEmailInputEvent.ClearNavigation) - }, showedErrorMessage = { viewModel.emit(InsuranceEvidenceEmailInputEvent.ClearErrorMessage) }, @@ -97,11 +91,9 @@ internal fun InsuranceEvidenceEmailInputDestination( @Composable private fun InsuranceEvidenceEmailInputScreen( uiState: InsuranceEvidenceEmailInputState, - navigateToShowCertificate: (url: String) -> Unit, navigateUp: () -> Unit, onRetry: () -> Unit, onChangeEmail: (email: String) -> Unit, - onClearNavigation: () -> Unit, onSubmit: () -> Unit, showedErrorMessage: () -> Unit, ) { @@ -120,13 +112,6 @@ private fun InsuranceEvidenceEmailInputScreen( } is InsuranceEvidenceEmailInputState.Success -> { - LaunchedEffect(uiState.fetchedCertificateUrl) { - val url = uiState.fetchedCertificateUrl - if (url != null) { - onClearNavigation() - navigateToShowCertificate(url) - } - } InsuranceEvidenceEmailSuccessScreen( uiState, onSubmit = onSubmit, @@ -313,8 +298,6 @@ private fun PreviewInsuranceEvidenceEmailInputScreen( {}, {}, {}, - {}, - {}, ) } } diff --git a/app/feature/feature-insurance-certificate/src/main/kotlin/com/hedvig/android/feature/insurance/certificate/ui/email/InsuranceEvidenceEmailInputViewModel.kt b/app/feature/feature-insurance-certificate/src/main/kotlin/com/hedvig/android/feature/insurance/certificate/ui/email/InsuranceEvidenceEmailInputViewModel.kt index 8c493321f5..62927b1237 100644 --- a/app/feature/feature-insurance-certificate/src/main/kotlin/com/hedvig/android/feature/insurance/certificate/ui/email/InsuranceEvidenceEmailInputViewModel.kt +++ b/app/feature/feature-insurance-certificate/src/main/kotlin/com/hedvig/android/feature/insurance/certificate/ui/email/InsuranceEvidenceEmailInputViewModel.kt @@ -11,10 +11,14 @@ import androidx.lifecycle.ViewModel import com.hedvig.android.core.common.di.AppScope import com.hedvig.android.feature.insurance.certificate.data.GenerateInsuranceEvidenceUseCase import com.hedvig.android.feature.insurance.certificate.data.GetInsuranceEvidenceInitialEmailUseCase +import com.hedvig.android.feature.insurance.certificate.navigation.InsuranceEvidenceKey +import com.hedvig.android.feature.insurance.certificate.navigation.ShowCertificateKey import com.hedvig.android.feature.insurance.certificate.ui.email.InsuranceEvidenceEmailInputState.Loading import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel +import com.hedvig.android.navigation.compose.Backstack +import com.hedvig.android.navigation.compose.navigateAndPopUpTo import com.hedvig.core.common.android.validation.validateEmail import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject @@ -32,17 +36,20 @@ import org.jetbrains.compose.resources.StringResource internal class InsuranceEvidenceEmailInputViewModel( generateInsuranceEvidenceUseCase: GenerateInsuranceEvidenceUseCase, getEmailUseCase: GetInsuranceEvidenceInitialEmailUseCase, + backstack: Backstack, ) : MoleculeViewModel( initialState = Loading, presenter = InsuranceEvidenceEmailInputPresenter( generateInsuranceEvidenceUseCase, getEmailUseCase, + backstack, ), ) internal class InsuranceEvidenceEmailInputPresenter( private val generateInsuranceEvidenceUseCase: GenerateInsuranceEvidenceUseCase, private val getEmailUseCase: GetInsuranceEvidenceInitialEmailUseCase, + private val backstack: Backstack, ) : MoleculePresenter { @Composable override fun MoleculePresenterScope.present( @@ -82,11 +89,6 @@ internal class InsuranceEvidenceEmailInputPresenter( ) } - InsuranceEvidenceEmailInputEvent.ClearNavigation -> { - val successScreenState = currentState as? InsuranceEvidenceEmailInputState.Success ?: return@CollectEvents - currentState = successScreenState.copy(fetchedCertificateUrl = null) - } - InsuranceEvidenceEmailInputEvent.RetryLoadData -> { loadIteration++ } @@ -128,10 +130,9 @@ internal class InsuranceEvidenceEmailInputPresenter( ) }, ifRight = { url -> - currentState = successScreenState.copy( - generatingErrorMessage = null, - fetchedCertificateUrl = url, - buttonLoading = false, + backstack.navigateAndPopUpTo( + ShowCertificateKey(url), + inclusive = true, ) }, ) @@ -151,7 +152,6 @@ internal sealed interface InsuranceEvidenceEmailInputState { val emailValidationErrorMessage: StringResource? = null, val buttonLoading: Boolean = false, val generatingErrorMessage: StringResource? = null, - val fetchedCertificateUrl: String? = null, ) : InsuranceEvidenceEmailInputState } @@ -163,6 +163,4 @@ internal sealed interface InsuranceEvidenceEmailInputEvent { data object RetryLoadData : InsuranceEvidenceEmailInputEvent data object ClearErrorMessage : InsuranceEvidenceEmailInputEvent - - data object ClearNavigation : InsuranceEvidenceEmailInputEvent } diff --git a/app/feature/feature-insurance-certificate/src/test/kotlin/InsuranceEvidenceEmailInputPresenterTest.kt b/app/feature/feature-insurance-certificate/src/test/kotlin/InsuranceEvidenceEmailInputPresenterTest.kt index a318900762..07413a96f3 100644 --- a/app/feature/feature-insurance-certificate/src/test/kotlin/InsuranceEvidenceEmailInputPresenterTest.kt +++ b/app/feature/feature-insurance-certificate/src/test/kotlin/InsuranceEvidenceEmailInputPresenterTest.kt @@ -16,25 +16,33 @@ import assertk.assertions.prop import com.hedvig.android.core.common.ErrorMessage import com.hedvig.android.feature.insurance.certificate.data.GenerateInsuranceEvidenceUseCase import com.hedvig.android.feature.insurance.certificate.data.GetInsuranceEvidenceInitialEmailUseCase +import com.hedvig.android.feature.insurance.certificate.navigation.ShowCertificateKey import com.hedvig.android.molecule.test.test +import com.hedvig.android.navigation.common.HedvigNavKey +import com.hedvig.android.navigation.compose.Backstack import hedvig.resources.Res import hedvig.resources.something_went_wrong import kotlinx.coroutines.test.runTest import org.junit.Test internal class InsuranceEvidenceEmailInputPresenterTest { - private fun createPresenterWithFakes(): Triple< - InsuranceEvidenceEmailInputPresenter, - FakeGetInsuranceEvidenceInitialEmailUseCase, - FakeGenerateInsuranceEvidenceUseCase, - > { + private data class Fakes( + val presenter: InsuranceEvidenceEmailInputPresenter, + val getEmailUseCase: FakeGetInsuranceEvidenceInitialEmailUseCase, + val generateUseCase: FakeGenerateInsuranceEvidenceUseCase, + val backstack: Backstack, + ) + + private fun createPresenterWithFakes(): Fakes { val getEmailUseCase = FakeGetInsuranceEvidenceInitialEmailUseCase() val generateUseCase = FakeGenerateInsuranceEvidenceUseCase() + val backstack = TestBackstack() val presenter = InsuranceEvidenceEmailInputPresenter( generateInsuranceEvidenceUseCase = generateUseCase, getEmailUseCase = getEmailUseCase, + backstack = backstack, ) - return Triple(presenter, getEmailUseCase, generateUseCase) + return Fakes(presenter, getEmailUseCase, generateUseCase, backstack) } @Test @@ -146,8 +154,8 @@ internal class InsuranceEvidenceEmailInputPresenterTest { } @Test - fun `when generating certificate succeeds update state with certificate url`() = runTest { - val (presenter, _, generateUseCase) = createPresenterWithFakes() + fun `when generating certificate succeeds navigate to show certificate`() = runTest { + val (presenter, _, generateUseCase, backstack) = createPresenterWithFakes() presenter.test(InsuranceEvidenceEmailInputState.Success(email = "valid@example.com")) { sendEvent(InsuranceEvidenceEmailInputEvent.Submit) skipItems(1) @@ -155,13 +163,10 @@ internal class InsuranceEvidenceEmailInputPresenterTest { .prop(InsuranceEvidenceEmailInputState.Success::buttonLoading) .isTrue() generateUseCase.resultTurbine.add("https://certificate.url".right()) - assertThat(awaitItem()).isInstanceOf(InsuranceEvidenceEmailInputState.Success::class) - .all { - prop(InsuranceEvidenceEmailInputState.Success::fetchedCertificateUrl) - .isEqualTo("https://certificate.url") - prop(InsuranceEvidenceEmailInputState.Success::buttonLoading) - .isFalse() - } + assertThat(backstack.entries.last()) + .isInstanceOf(ShowCertificateKey::class) + .prop(ShowCertificateKey::certificateUrl) + .isEqualTo("https://certificate.url") } } @@ -183,25 +188,6 @@ internal class InsuranceEvidenceEmailInputPresenterTest { } } - @Test - fun `when clear navigation is clicked remove certificate url from state`() = runTest { - val (presenter, _, _) = createPresenterWithFakes() - presenter.test( - InsuranceEvidenceEmailInputState.Success( - "test@example.com", - fetchedCertificateUrl = "https://certificate.url", - ), - ) { - assertThat(awaitItem()).isInstanceOf(InsuranceEvidenceEmailInputState.Success::class) - .prop(InsuranceEvidenceEmailInputState.Success::fetchedCertificateUrl) - .isNotNull() - sendEvent(InsuranceEvidenceEmailInputEvent.ClearNavigation) - assertThat(awaitItem()).isInstanceOf(InsuranceEvidenceEmailInputState.Success::class) - .prop(InsuranceEvidenceEmailInputState.Success::fetchedCertificateUrl) - .isNull() - } - } - @Test fun `when clear error message is clicked remove error message from state`() = runTest { val (presenter, _, _) = createPresenterWithFakes() @@ -257,6 +243,10 @@ internal class InsuranceEvidenceEmailInputPresenterTest { } } +private class TestBackstack( + override val entries: MutableList = mutableListOf(), +) : Backstack + private class FakeGetInsuranceEvidenceInitialEmailUseCase : GetInsuranceEvidenceInitialEmailUseCase { val emailTurbine = Turbine>() diff --git a/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/MovingFlowEntries.kt b/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/MovingFlowEntries.kt index 1cc93a3e0a..6de7995c9e 100644 --- a/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/MovingFlowEntries.kt +++ b/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/MovingFlowEntries.kt @@ -16,8 +16,6 @@ import com.hedvig.android.feature.movingflow.ui.summary.SummaryDestination import com.hedvig.android.feature.movingflow.ui.summary.SummaryViewModel import com.hedvig.android.navigation.common.HedvigNavKey import com.hedvig.android.navigation.compose.Backstack -import com.hedvig.android.navigation.compose.add -import com.hedvig.android.navigation.compose.navigateAndPopUpTo import com.hedvig.android.navigation.compose.popBackstack import com.hedvig.android.navigation.compose.popUpTo import com.hedvig.android.shared.tier.comparison.navigation.ComparisonParameters @@ -67,29 +65,17 @@ fun EntryProviderScope.movingFlowEntries(backstack: Backstack, goT viewModel = metroViewModel(), navigateUp = backstack::navigateUp, exitFlow = { backstack.popUpTo(inclusive = true) }, - onNavigateToNextStep = { moveIntentId, shouldPopUp -> - if (shouldPopUp) { - backstack.navigateAndPopUpTo( - HousingTypeKey(moveIntentId), - inclusive = true, - ) - } else { - backstack.add(HousingTypeKey(moveIntentId)) - } - }, goToChat = goToChat, ) } entry { key -> - val moveIntentId = key.moveIntentId HousingTypeDestination( - viewModel = metroViewModel(), + viewModel = assistedMetroViewModel { + create(key.moveIntentId) + }, navigateUp = backstack::navigateUp, exitFlow = { backstack.exitMovingFlow() }, - onNavigateToNextStep = { - backstack.add(EnterNewAddressKey(moveIntentId)) - }, ) } entry { key -> @@ -101,12 +87,6 @@ fun EntryProviderScope.movingFlowEntries(backstack: Backstack, goT navigateUp = backstack::navigateUp, popBackstack = backstack::popBackstack, exitFlow = { backstack.exitMovingFlow() }, - onNavigateToAddHouseInformation = { - backstack.add(AddHouseInformationKey(moveIntentId)) - }, - onNavigateToChoseCoverageLevelAndDeductible = { - backstack.add(ChoseCoverageLevelAndDeductibleKey(moveIntentId)) - }, ) } entry { key -> @@ -118,9 +98,6 @@ fun EntryProviderScope.movingFlowEntries(backstack: Backstack, goT navigateUp = backstack::navigateUp, popBackstack = backstack::popBackstack, exitFlow = { backstack.exitMovingFlow() }, - onNavigateToChoseCoverageLevelAndDeductible = { - backstack.add(ChoseCoverageLevelAndDeductibleKey(moveIntentId)) - }, ) } entry { key -> @@ -135,12 +112,6 @@ fun EntryProviderScope.movingFlowEntries(backstack: Backstack, goT navigateUp = backstack::navigateUp, popBackstack = backstack::popBackstack, exitFlow = { backstack.exitMovingFlow() }, - onNavigateToSummaryScreen = { homeQuoteId -> - backstack.add(SummaryKey(moveIntentId, homeQuoteId)) - }, - navigateToComparison = { parameters -> - backstack.add(CompareCoverageKey(parameters)) - }, ) } @@ -165,13 +136,6 @@ fun EntryProviderScope.movingFlowEntries(backstack: Backstack, goT navigateUp = backstack::navigateUp, navigateBack = backstack::popBackstack, exitFlow = { backstack.exitMovingFlow() }, - onNavigateToFinishedScreen = { moveDate -> - backstack.popUpTo(inclusive = true) - backstack.navigateAndPopUpTo( - SuccessfulMoveKey(moveDate), - inclusive = true, - ) - }, ) } diff --git a/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/ui/addhouseinformation/AddHouseInformationDestination.kt b/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/ui/addhouseinformation/AddHouseInformationDestination.kt index e99dc4a32e..2912ed2fbd 100644 --- a/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/ui/addhouseinformation/AddHouseInformationDestination.kt +++ b/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/ui/addhouseinformation/AddHouseInformationDestination.kt @@ -22,7 +22,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf @@ -83,7 +82,6 @@ import com.hedvig.android.feature.movingflow.data.MovingFlowState.PropertyState. import com.hedvig.android.feature.movingflow.data.MovingFlowState.PropertyState.HouseState.MoveExtraBuildingType import com.hedvig.android.feature.movingflow.ui.MovingFlowTopAppBar import com.hedvig.android.feature.movingflow.ui.addhouseinformation.AddHouseInformationEvent.DismissSubmissionError -import com.hedvig.android.feature.movingflow.ui.addhouseinformation.AddHouseInformationEvent.NavigatedToChoseCoverage import com.hedvig.android.feature.movingflow.ui.addhouseinformation.AddHouseInformationEvent.Submit import com.hedvig.android.feature.movingflow.ui.addhouseinformation.AddHouseInformationUiState.Content import com.hedvig.android.feature.movingflow.ui.addhouseinformation.AddHouseInformationUiState.Content.SubmittingInfoFailure.NetworkFailure @@ -126,15 +124,8 @@ internal fun AddHouseInformationDestination( navigateUp: () -> Unit, popBackstack: () -> Unit, exitFlow: () -> Unit, - onNavigateToChoseCoverageLevelAndDeductible: () -> Unit, ) { val uiState = viewModel.uiState.collectAsStateWithLifecycle().value - if (uiState is Content && uiState.navigateToChoseCoverage) { - LaunchedEffect(Unit) { - viewModel.emit(NavigatedToChoseCoverage) - onNavigateToChoseCoverageLevelAndDeductible() - } - } AddHouseInformationScreen( uiState = uiState, navigateUp = navigateUp, @@ -509,7 +500,6 @@ private fun PreviewAddHouseInformationScreen() { ), isLoadingNextStep = false, submittingInfoFailure = null, - navigateToChoseCoverage = false, ), navigateUp = {}, popBackstack = {}, diff --git a/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/ui/addhouseinformation/AddHouseInformationViewModel.kt b/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/ui/addhouseinformation/AddHouseInformationViewModel.kt index 1878219acb..60b9cc8ef9 100644 --- a/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/ui/addhouseinformation/AddHouseInformationViewModel.kt +++ b/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/ui/addhouseinformation/AddHouseInformationViewModel.kt @@ -18,6 +18,7 @@ import arrow.core.raise.ensureNotNull import com.apollographql.apollo.ApolloClient import com.hedvig.android.apollo.safeExecute import com.hedvig.android.core.common.di.AppScope +import com.hedvig.android.feature.movingflow.ChoseCoverageLevelAndDeductibleKey import com.hedvig.android.feature.movingflow.compose.BooleanInput import com.hedvig.android.feature.movingflow.compose.ConstrainedNumberInput import com.hedvig.android.feature.movingflow.compose.ListInput @@ -27,7 +28,6 @@ import com.hedvig.android.feature.movingflow.data.MovingFlowState.PropertyState. import com.hedvig.android.feature.movingflow.data.MovingFlowState.PropertyState.HouseState.MoveExtraBuildingType import com.hedvig.android.feature.movingflow.storage.MovingFlowRepository import com.hedvig.android.feature.movingflow.ui.addhouseinformation.AddHouseInformationEvent.DismissSubmissionError -import com.hedvig.android.feature.movingflow.ui.addhouseinformation.AddHouseInformationEvent.NavigatedToChoseCoverage import com.hedvig.android.feature.movingflow.ui.addhouseinformation.AddHouseInformationEvent.Submit import com.hedvig.android.feature.movingflow.ui.addhouseinformation.AddHouseInformationUiState.Content import com.hedvig.android.feature.movingflow.ui.addhouseinformation.AddHouseInformationUiState.Content.SubmittingInfoFailure @@ -39,6 +39,8 @@ import com.hedvig.android.featureflags.flags.Feature import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel +import com.hedvig.android.navigation.compose.Backstack +import com.hedvig.android.navigation.compose.add import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject @@ -59,6 +61,7 @@ internal class AddHouseInformationViewModel( movingFlowRepository: MovingFlowRepository, apolloClient: ApolloClient, featureManager: FeatureManager, + backstack: Backstack, ) : MoleculeViewModel( Loading, AddHouseInformationPresenter( @@ -66,6 +69,7 @@ internal class AddHouseInformationViewModel( movingFlowRepository, apolloClient, featureManager, + backstack, ), ) { @AssistedFactory @@ -83,6 +87,7 @@ internal class AddHouseInformationPresenter( private val movingFlowRepository: MovingFlowRepository, private val apolloClient: ApolloClient, private val featureManager: FeatureManager, + private val backstack: Backstack, ) : MoleculePresenter { @Composable override fun MoleculePresenterScope.present( @@ -98,7 +103,6 @@ internal class AddHouseInformationPresenter( ) } var submittingInfoFailure: SubmittingInfoFailure? by remember { mutableStateOf(null) } - var navigateToChoseCoverage by remember { mutableStateOf(false) } var inputForSubmission: InputForSubmission? by remember { mutableStateOf(null) } LaunchedEffect(Unit) { @@ -131,10 +135,6 @@ internal class AddHouseInformationPresenter( } } - NavigatedToChoseCoverage -> { - navigateToChoseCoverage = false - } - DismissSubmissionError -> { submittingInfoFailure = null } @@ -171,7 +171,7 @@ internal class AddHouseInformationPresenter( else -> { movingFlowRepository.updateWithMoveIntentQuotes(moveIntentQuotesFragment) - navigateToChoseCoverage = true + backstack.add(ChoseCoverageLevelAndDeductibleKey(moveIntentId)) } } }, @@ -194,7 +194,6 @@ internal class AddHouseInformationPresenter( addressInput = value, isLoadingNextStep = inputForSubmission != null, submittingInfoFailure = submittingInfoFailure, - navigateToChoseCoverage = navigateToChoseCoverage, ) } } @@ -239,8 +238,6 @@ internal sealed interface AddHouseInformationEvent { data object Submit : AddHouseInformationEvent data object DismissSubmissionError : AddHouseInformationEvent - - data object NavigatedToChoseCoverage : AddHouseInformationEvent } @Stable @@ -254,11 +251,9 @@ internal sealed interface AddHouseInformationUiState { val addressInput: AddressInput, val isLoadingNextStep: Boolean, val submittingInfoFailure: SubmittingInfoFailure?, - val navigateToChoseCoverage: Boolean, ) : AddHouseInformationUiState { val shouldDisableInput: Boolean = submittingInfoFailure != null || - isLoadingNextStep == true || - navigateToChoseCoverage == true + isLoadingNextStep == true sealed interface SubmittingInfoFailure { data object NetworkFailure : SubmittingInfoFailure diff --git a/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/ui/chosecoveragelevelanddeductible/ChoseCoverageLevelAndDeductibleDestination.kt b/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/ui/chosecoveragelevelanddeductible/ChoseCoverageLevelAndDeductibleDestination.kt index afb3c3f0e5..d2dff92cd1 100644 --- a/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/ui/chosecoveragelevelanddeductible/ChoseCoverageLevelAndDeductibleDestination.kt +++ b/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/ui/chosecoveragelevelanddeductible/ChoseCoverageLevelAndDeductibleDestination.kt @@ -24,7 +24,6 @@ import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll 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 @@ -85,7 +84,6 @@ import com.hedvig.android.feature.movingflow.ui.chosecoveragelevelanddeductible. import com.hedvig.android.feature.movingflow.ui.chosecoveragelevelanddeductible.DeductibleOptions.MutlipleOptions import com.hedvig.android.feature.movingflow.ui.chosecoveragelevelanddeductible.DeductibleOptions.NoOptions import com.hedvig.android.feature.movingflow.ui.chosecoveragelevelanddeductible.DeductibleOptions.OneOption -import com.hedvig.android.shared.tier.comparison.navigation.ComparisonParameters import com.hedvig.ui.tiersandaddons.CostBreakdownEntry import com.hedvig.ui.tiersandaddons.DiscountCostBreakdown import hedvig.resources.CHANGE_ADDRESS_PRICE_PER_MONTH_LABEL @@ -119,22 +117,8 @@ internal fun ChoseCoverageLevelAndDeductibleDestination( navigateUp: () -> Unit, popBackstack: () -> Unit, exitFlow: () -> Unit, - onNavigateToSummaryScreen: (homeQuoteId: String) -> Unit, - navigateToComparison: (comparisonParameters: ComparisonParameters) -> Unit, ) { val uiState = viewModel.uiState.collectAsStateWithLifecycle().value - if (uiState is Content && uiState.navigateToSummaryScreenWithHomeQuoteId != null) { - LaunchedEffect(uiState.navigateToSummaryScreenWithHomeQuoteId) { - viewModel.emit(ChoseCoverageLevelAndDeductibleEvent.NavigatedToSummary) - onNavigateToSummaryScreen(uiState.navigateToSummaryScreenWithHomeQuoteId) - } - } - if (uiState is Content && uiState.comparisonParameters != null) { - LaunchedEffect(uiState.comparisonParameters) { - viewModel.emit(ChoseCoverageLevelAndDeductibleEvent.ClearNavigateToComparison) - navigateToComparison(uiState.comparisonParameters) - } - } ChoseCoverageLevelAndDeductibleScreen( onCompareCoverageClicked = { viewModel.emit(ChoseCoverageLevelAndDeductibleEvent.LaunchComparison) @@ -704,9 +688,7 @@ fun PreviewChoseCoverageLevelAndDeductibleScreen() { }, premium = UiMoney(100.0, SEK), grossPremium = UiMoney(110.0, SEK), - navigateToSummaryScreenWithHomeQuoteId = null, isSubmitting = false, - comparisonParameters = null, ), navigateUp = {}, popBackstack = {}, diff --git a/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/ui/chosecoveragelevelanddeductible/ChoseCoverageLevelAndDeductibleViewModel.kt b/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/ui/chosecoveragelevelanddeductible/ChoseCoverageLevelAndDeductibleViewModel.kt index b33d69673b..7ef268df45 100644 --- a/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/ui/chosecoveragelevelanddeductible/ChoseCoverageLevelAndDeductibleViewModel.kt +++ b/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/ui/chosecoveragelevelanddeductible/ChoseCoverageLevelAndDeductibleViewModel.kt @@ -7,22 +7,21 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshots.Snapshot import arrow.core.None import arrow.core.Option import arrow.core.Some import arrow.core.some import com.hedvig.android.core.common.di.AppScope import com.hedvig.android.core.uidata.UiMoney +import com.hedvig.android.feature.movingflow.CompareCoverageKey +import com.hedvig.android.feature.movingflow.SummaryKey import com.hedvig.android.feature.movingflow.data.AddonId import com.hedvig.android.feature.movingflow.data.MovingFlowQuotes.MoveHomeQuote import com.hedvig.android.feature.movingflow.data.MovingFlowQuotes.MoveHomeQuote.Deductible import com.hedvig.android.feature.movingflow.data.MovingFlowQuotes.MoveMtaQuote import com.hedvig.android.feature.movingflow.storage.MovingFlowRepository import com.hedvig.android.feature.movingflow.ui.chosecoveragelevelanddeductible.ChoseCoverageLevelAndDeductibleEvent.AlterAddon -import com.hedvig.android.feature.movingflow.ui.chosecoveragelevelanddeductible.ChoseCoverageLevelAndDeductibleEvent.ClearNavigateToComparison import com.hedvig.android.feature.movingflow.ui.chosecoveragelevelanddeductible.ChoseCoverageLevelAndDeductibleEvent.LaunchComparison -import com.hedvig.android.feature.movingflow.ui.chosecoveragelevelanddeductible.ChoseCoverageLevelAndDeductibleEvent.NavigatedToSummary import com.hedvig.android.feature.movingflow.ui.chosecoveragelevelanddeductible.ChoseCoverageLevelAndDeductibleEvent.SelectCoverage import com.hedvig.android.feature.movingflow.ui.chosecoveragelevelanddeductible.ChoseCoverageLevelAndDeductibleEvent.SelectDeductible import com.hedvig.android.feature.movingflow.ui.chosecoveragelevelanddeductible.ChoseCoverageLevelAndDeductibleEvent.SubmitSelectedHomeQuoteId @@ -34,6 +33,8 @@ import com.hedvig.android.feature.movingflow.ui.summary.MoveIntentCost import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel +import com.hedvig.android.navigation.compose.Backstack +import com.hedvig.android.navigation.compose.add import com.hedvig.android.shared.tier.comparison.navigation.ComparisonParameters import com.hedvig.ui.tiersandaddons.CostBreakdownEntry import dev.zacsweers.metro.Assisted @@ -50,9 +51,10 @@ internal class ChoseCoverageLevelAndDeductibleViewModel( @Assisted intentId: String, movingFlowRepository: MovingFlowRepository, getMoveIntentCostUseCase: GetMoveIntentCostUseCase, + backstack: Backstack, ) : MoleculeViewModel( ChoseCoverageLevelAndDeductibleUiState.Loading, - ChoseCoverageLevelAndDeductiblePresenter(intentId, movingFlowRepository, getMoveIntentCostUseCase), + ChoseCoverageLevelAndDeductiblePresenter(intentId, movingFlowRepository, getMoveIntentCostUseCase, backstack), ) { @AssistedFactory @ManualViewModelAssistedFactoryKey @@ -68,6 +70,7 @@ private class ChoseCoverageLevelAndDeductiblePresenter( private val intentId: String, private val movingFlowRepository: MovingFlowRepository, private val getMoveIntentCostUseCase: GetMoveIntentCostUseCase, + private val backstack: Backstack, ) : MoleculePresenter { @Composable override fun MoleculePresenterScope.present( @@ -79,8 +82,6 @@ private class ChoseCoverageLevelAndDeductiblePresenter( ) } var submittingSelectedHomeQuoteId: String? by remember { mutableStateOf(null) } - var navigateToSummaryScreenWithHomeQuoteId: String? by remember { mutableStateOf(null) } - var comparisonParameters: ComparisonParameters? by remember { mutableStateOf(null) } val moveIntentCost: MoveIntentCost? by produceState(null, tiersInfo.getOrNull()?.selectedCoverage) { val selectedCoverage = tiersInfo.getOrNull()?.selectedCoverage ?: return@produceState getMoveIntentCostUseCase.invoke( @@ -158,21 +159,17 @@ private class ChoseCoverageLevelAndDeductiblePresenter( submittingSelectedHomeQuoteId = event.homeQuoteId } - NavigatedToSummary -> { - navigateToSummaryScreenWithHomeQuoteId = null - } - - ClearNavigateToComparison -> { - comparisonParameters = null - } - LaunchComparison -> { val currentContent = tiersInfo.getOrNull() ?: return@CollectEvents val filtered = currentContent.allOptions.distinctBy { it.tierName } val selected = filtered.firstOrNull { it.tierName == currentContent.selectedCoverage.tierName } - comparisonParameters = ComparisonParameters( - termsIds = filtered.map { it.productVariant.termsVersion }, - selectedTermsVersion = selected?.productVariant?.termsVersion, + backstack.add( + CompareCoverageKey( + ComparisonParameters( + termsIds = filtered.map { it.productVariant.termsVersion }, + selectedTermsVersion = selected?.productVariant?.termsVersion, + ), + ), ) } } @@ -229,10 +226,8 @@ private class ChoseCoverageLevelAndDeductiblePresenter( LaunchedEffect(submittingSelectedHomeQuoteId) { val submittingSelectedHomeQuoteIdValue = submittingSelectedHomeQuoteId ?: return@LaunchedEffect movingFlowRepository.updatePreselectedHomeQuoteId(submittingSelectedHomeQuoteIdValue) - Snapshot.withMutableSnapshot { - submittingSelectedHomeQuoteId = null - navigateToSummaryScreenWithHomeQuoteId = submittingSelectedHomeQuoteIdValue - } + submittingSelectedHomeQuoteId = null + backstack.add(SummaryKey(intentId, submittingSelectedHomeQuoteIdValue)) } return when (val tiersInfoValue = tiersInfo) { @@ -253,11 +248,9 @@ private class ChoseCoverageLevelAndDeductiblePresenter( ChoseCoverageLevelAndDeductibleUiState.Content( tiersInfo = info, costBreakdown = costBreakdown, - navigateToSummaryScreenWithHomeQuoteId = navigateToSummaryScreenWithHomeQuoteId, premium = selectedQuoteCost?.monthlyNet, grossPremium = selectedQuoteCost?.monthlyGross, isSubmitting = submittingSelectedHomeQuoteId != null, - comparisonParameters = comparisonParameters, ) } } @@ -275,11 +268,7 @@ internal sealed interface ChoseCoverageLevelAndDeductibleEvent { data class SubmitSelectedHomeQuoteId(val homeQuoteId: String) : ChoseCoverageLevelAndDeductibleEvent - data object NavigatedToSummary : ChoseCoverageLevelAndDeductibleEvent - data object LaunchComparison : ChoseCoverageLevelAndDeductibleEvent - - data object ClearNavigateToComparison : ChoseCoverageLevelAndDeductibleEvent } internal sealed interface ChoseCoverageLevelAndDeductibleUiState { @@ -290,10 +279,8 @@ internal sealed interface ChoseCoverageLevelAndDeductibleUiState { data class Content( val tiersInfo: TiersInfo, val costBreakdown: List?, - val comparisonParameters: ComparisonParameters?, val premium: UiMoney?, val grossPremium: UiMoney?, - val navigateToSummaryScreenWithHomeQuoteId: String?, val isSubmitting: Boolean, ) : ChoseCoverageLevelAndDeductibleUiState { val canSubmit = tiersInfo.selectedHomeQuoteId != null && !isSubmitting diff --git a/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/ui/enternewaddress/EnterNewAddressDestination.kt b/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/ui/enternewaddress/EnterNewAddressDestination.kt index 6b340fcb60..6c867cc065 100644 --- a/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/ui/enternewaddress/EnterNewAddressDestination.kt +++ b/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/ui/enternewaddress/EnterNewAddressDestination.kt @@ -107,22 +107,8 @@ internal fun EnterNewAddressDestination( navigateUp: () -> Unit, popBackstack: () -> Unit, exitFlow: () -> Unit, - onNavigateToAddHouseInformation: () -> Unit, - onNavigateToChoseCoverageLevelAndDeductible: () -> Unit, ) { val uiState = viewModel.uiState.collectAsStateWithLifecycle().value - if (uiState is Content && uiState.navigateToChoseCoverage) { - LaunchedEffect(uiState.navigateToChoseCoverage) { - viewModel.emit(EnterNewAddressEvent.NavigatedToChoseCoverage) - onNavigateToChoseCoverageLevelAndDeductible() - } - } - if (uiState is Content && uiState.navigateToAddHouseInformation) { - LaunchedEffect(uiState.navigateToAddHouseInformation) { - viewModel.emit(EnterNewAddressEvent.NavigatedToAddHouseInformation) - onNavigateToAddHouseInformation() - } - } EnterNewAddressScreen( uiState = uiState, navigateUp = navigateUp, @@ -448,8 +434,6 @@ fun PreviewEnterNewAddressScreen() { propertyType = PropertyType.House, submittingInfoFailure = null, isLoadingNextStep = false, - navigateToChoseCoverage = false, - navigateToAddHouseInformation = false, ), submitInput = {}, dismissSubmissionError = {}, diff --git a/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/ui/enternewaddress/EnterNewAddressViewModel.kt b/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/ui/enternewaddress/EnterNewAddressViewModel.kt index 5c9b40304b..cda92e0a2d 100644 --- a/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/ui/enternewaddress/EnterNewAddressViewModel.kt +++ b/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/ui/enternewaddress/EnterNewAddressViewModel.kt @@ -20,6 +20,8 @@ import com.apollographql.apollo.ApolloClient import com.apollographql.apollo.api.Optional import com.hedvig.android.apollo.safeExecute import com.hedvig.android.core.common.di.AppScope +import com.hedvig.android.feature.movingflow.AddHouseInformationKey +import com.hedvig.android.feature.movingflow.ChoseCoverageLevelAndDeductibleKey import com.hedvig.android.feature.movingflow.compose.BooleanInput import com.hedvig.android.feature.movingflow.compose.ConstrainedNumberInput import com.hedvig.android.feature.movingflow.compose.ValidatedInput @@ -33,8 +35,6 @@ import com.hedvig.android.feature.movingflow.data.MovingFlowState.PropertyState. import com.hedvig.android.feature.movingflow.data.MovingFlowState.PropertyState.HouseState import com.hedvig.android.feature.movingflow.storage.MovingFlowRepository import com.hedvig.android.feature.movingflow.ui.enternewaddress.EnterNewAddressEvent.DismissSubmissionError -import com.hedvig.android.feature.movingflow.ui.enternewaddress.EnterNewAddressEvent.NavigatedToAddHouseInformation -import com.hedvig.android.feature.movingflow.ui.enternewaddress.EnterNewAddressEvent.NavigatedToChoseCoverage import com.hedvig.android.feature.movingflow.ui.enternewaddress.EnterNewAddressEvent.Submit import com.hedvig.android.feature.movingflow.ui.enternewaddress.EnterNewAddressUiState.Content import com.hedvig.android.feature.movingflow.ui.enternewaddress.EnterNewAddressUiState.Content.PropertyType.Apartment @@ -55,6 +55,8 @@ import com.hedvig.android.featureflags.flags.Feature import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel +import com.hedvig.android.navigation.compose.Backstack +import com.hedvig.android.navigation.compose.add import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject @@ -78,6 +80,7 @@ internal class EnterNewAddressViewModel( movingFlowRepository: MovingFlowRepository, apolloClient: ApolloClient, featureManager: FeatureManager, + backstack: Backstack, ) : MoleculeViewModel( Loading, EnterNewAddressPresenter( @@ -85,6 +88,7 @@ internal class EnterNewAddressViewModel( movingFlowRepository, apolloClient, featureManager, + backstack, ), ) { @AssistedFactory @@ -102,6 +106,7 @@ private class EnterNewAddressPresenter( private val movingFlowRepository: MovingFlowRepository, private val apolloClient: ApolloClient, private val featureManager: FeatureManager, + private val backstack: Backstack, ) : MoleculePresenter { @Composable override fun MoleculePresenterScope.present( @@ -117,8 +122,6 @@ private class EnterNewAddressPresenter( ) } var submittingInfoFailure: SubmittingInfoFailure? by remember { mutableStateOf(null) } - var navigateToChoseCoverage by remember { mutableStateOf(false) } - var navigateToAddHouseInformation by remember { mutableStateOf(false) } var inputForSubmission: InputForSubmission? by remember { mutableStateOf(null) } LaunchedEffect(Unit) { @@ -132,14 +135,6 @@ private class EnterNewAddressPresenter( val coroutineScope = rememberCoroutineScope() CollectEvents { event -> when (event) { - NavigatedToChoseCoverage -> { - navigateToChoseCoverage = false - } - - NavigatedToAddHouseInformation -> { - navigateToAddHouseInformation = false - } - DismissSubmissionError -> { submittingInfoFailure = null } @@ -160,7 +155,7 @@ private class EnterNewAddressPresenter( ) when (content.propertyType) { House -> { - navigateToAddHouseInformation = true + backstack.add(AddHouseInformationKey(moveIntentId)) } is Apartment -> { @@ -202,7 +197,7 @@ private class EnterNewAddressPresenter( else -> { movingFlowRepository.updateWithMoveIntentQuotes(moveIntentQuotesFragment) - navigateToChoseCoverage = true + backstack.add(ChoseCoverageLevelAndDeductibleKey(moveIntentId)) } } }, @@ -222,8 +217,6 @@ private class EnterNewAddressPresenter( else -> state.copy( submittingInfoFailure = submittingInfoFailure, - navigateToChoseCoverage = navigateToChoseCoverage, - navigateToAddHouseInformation = navigateToAddHouseInformation, isLoadingNextStep = inputForSubmission != null, ) } @@ -277,10 +270,6 @@ private data class InputForSubmission( internal sealed interface EnterNewAddressEvent { data object Submit : EnterNewAddressEvent - data object NavigatedToChoseCoverage : EnterNewAddressEvent - - data object NavigatedToAddHouseInformation : EnterNewAddressEvent - data object DismissSubmissionError : EnterNewAddressEvent } @@ -301,13 +290,9 @@ internal sealed interface EnterNewAddressUiState { val propertyType: PropertyType, val submittingInfoFailure: SubmittingInfoFailure?, val isLoadingNextStep: Boolean, - val navigateToChoseCoverage: Boolean, - val navigateToAddHouseInformation: Boolean, ) : EnterNewAddressUiState { val shouldDisableInput: Boolean = submittingInfoFailure != null || - isLoadingNextStep == true || - navigateToChoseCoverage == true || - navigateToAddHouseInformation == true + isLoadingNextStep == true sealed interface PropertyType { data object House : PropertyType @@ -468,8 +453,6 @@ private fun MovingFlowState.toContent(): Content { }, submittingInfoFailure = null, isLoadingNextStep = false, - navigateToChoseCoverage = false, - navigateToAddHouseInformation = false, ) } diff --git a/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/ui/selectcontract/SelectContractDestination.kt b/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/ui/selectcontract/SelectContractDestination.kt index 4f79fe7cda..8472e096fd 100644 --- a/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/ui/selectcontract/SelectContractDestination.kt +++ b/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/ui/selectcontract/SelectContractDestination.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.PreviewParameter @@ -50,17 +49,8 @@ internal fun SelectContractDestination( navigateUp: () -> Unit, exitFlow: () -> Unit, goToChat: () -> Unit, - onNavigateToNextStep: (moveIntentId: String, popUpDestination: Boolean) -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - LaunchedEffect(uiState) { - val uiStateValue = uiState as? SelectContractState.NotEmpty ?: return@LaunchedEffect - if (uiStateValue.navigateToHousingType) { - val shouldPopUp = uiStateValue.intent.currentHomeAddresses.size < 2 - viewModel.emit(SelectContractEvent.ClearNavigation) - onNavigateToNextStep(uiStateValue.intent.id, shouldPopUp) - } - } SelectContractScreen( uiState = uiState, navigateUp = navigateUp, @@ -237,18 +227,15 @@ private class ChooseInsuranceUiStateProvider : SelectContractState.NotEmpty.Redirecting( intent = previewMovingIntent, selectedAddress = previewMovingIntent.currentHomeAddresses[0], - navigateToHousingType = false, ), SelectContractState.NotEmpty.Content( intent = previewMovingIntent, selectedAddress = null, - navigateToHousingType = false, buttonLoading = false, ), SelectContractState.NotEmpty.Content( intent = previewMovingIntent, selectedAddress = previewMovingIntent.currentHomeAddresses[1], - navigateToHousingType = false, buttonLoading = false, ), ), diff --git a/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/ui/selectcontract/SelectContractViewModel.kt b/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/ui/selectcontract/SelectContractViewModel.kt index c1258da796..4f8cea95c2 100644 --- a/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/ui/selectcontract/SelectContractViewModel.kt +++ b/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/ui/selectcontract/SelectContractViewModel.kt @@ -16,6 +16,8 @@ import com.hedvig.android.apollo.ErrorMessage import com.hedvig.android.apollo.safeExecute import com.hedvig.android.core.common.ErrorMessage import com.hedvig.android.core.common.di.AppScope +import com.hedvig.android.feature.movingflow.HousingTypeKey +import com.hedvig.android.feature.movingflow.SelectContractForMovingKey import com.hedvig.android.feature.movingflow.storage.MovingFlowRepository import com.hedvig.android.feature.movingflow.ui.selectcontract.SelectContractState.NotEmpty import com.hedvig.android.feature.movingflow.ui.selectcontract.SelectContractState.NotEmpty.Content @@ -23,6 +25,9 @@ import com.hedvig.android.feature.movingflow.ui.selectcontract.SelectContractSta import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel +import com.hedvig.android.navigation.compose.Backstack +import com.hedvig.android.navigation.compose.add +import com.hedvig.android.navigation.compose.navigateAndPopUpTo import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject import dev.zacsweers.metro.binding @@ -36,14 +41,16 @@ import octopus.feature.movingflow.fragment.MoveIntentFragment internal class SelectContractViewModel( apolloClient: ApolloClient, movingFlowRepository: MovingFlowRepository, + backstack: Backstack, ) : MoleculeViewModel( - presenter = SelectContractPresenter(apolloClient, movingFlowRepository), + presenter = SelectContractPresenter(apolloClient, movingFlowRepository, backstack), initialState = SelectContractState.Loading, ) internal class SelectContractPresenter( private val apolloClient: ApolloClient, private val movingFlowRepository: MovingFlowRepository, + private val backstack: Backstack, ) : MoleculePresenter { @Composable override fun MoleculePresenterScope.present( @@ -55,11 +62,6 @@ internal class SelectContractPresenter( CollectEvents { event -> when (event) { - SelectContractEvent.ClearNavigation -> { - val state = currentState as? Content ?: return@CollectEvents - currentState = state.copy(navigateToHousingType = false) - } - is SelectContractEvent.SelectContract -> { val state = currentState as? Content ?: return@CollectEvents currentState = state.copy( @@ -89,9 +91,17 @@ internal class SelectContractPresenter( val moveIntent = state.intent movingFlowRepository.initiateNewMovingFlow(moveIntent, id) submittingAddressId = null - currentState = when (state) { - is Content -> state.copy(navigateToHousingType = true, buttonLoading = false) - is Redirecting -> state.copy(navigateToHousingType = true) + if (state is Content) { + currentState = state.copy(buttonLoading = false) + } + val shouldPopUp = moveIntent.currentHomeAddresses.size < 2 + if (shouldPopUp) { + backstack.navigateAndPopUpTo( + HousingTypeKey(moveIntent.id), + inclusive = true, + ) + } else { + backstack.add(HousingTypeKey(moveIntent.id)) } } } @@ -130,7 +140,6 @@ internal class SelectContractPresenter( currentState = Content( intent = intent, selectedAddress = null, - navigateToHousingType = false, buttonLoading = false, ) } @@ -141,7 +150,6 @@ internal class SelectContractPresenter( currentState = Redirecting( intent = intent, selectedAddress = intent.currentHomeAddresses[0], - navigateToHousingType = false, ) } } @@ -165,8 +173,6 @@ internal sealed interface SelectContractEvent { data object SubmitContract : SelectContractEvent - data object ClearNavigation : SelectContractEvent - data object RetryLoadData : SelectContractEvent } @@ -182,18 +188,15 @@ internal sealed interface SelectContractState { sealed interface NotEmpty : SelectContractState { val intent: MoveIntentFragment val selectedAddress: MoveIntentFragment.CurrentHomeAddress? - val navigateToHousingType: Boolean data class Redirecting( override val intent: MoveIntentFragment, override val selectedAddress: MoveIntentFragment.CurrentHomeAddress, - override val navigateToHousingType: Boolean, ) : NotEmpty data class Content( override val intent: MoveIntentFragment, override val selectedAddress: MoveIntentFragment.CurrentHomeAddress?, - override val navigateToHousingType: Boolean, val buttonLoading: Boolean, ) : NotEmpty } diff --git a/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/ui/start/HousingTypeDestination.kt b/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/ui/start/HousingTypeDestination.kt index f2a1c13cec..98790e0b10 100644 --- a/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/ui/start/HousingTypeDestination.kt +++ b/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/ui/start/HousingTypeDestination.kt @@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider @@ -50,21 +49,8 @@ import hedvig.resources.something_went_wrong import org.jetbrains.compose.resources.stringResource @Composable -internal fun HousingTypeDestination( - viewModel: HousingTypeViewModel, - navigateUp: () -> Unit, - exitFlow: () -> Unit, - onNavigateToNextStep: () -> Unit, -) { +internal fun HousingTypeDestination(viewModel: HousingTypeViewModel, navigateUp: () -> Unit, exitFlow: () -> Unit) { val uiState = viewModel.uiState.collectAsStateWithLifecycle().value - if (uiState is Content) { - LaunchedEffect(uiState.navigateToNextStep) { - if (uiState.navigateToNextStep != false) { - viewModel.emit(HousingTypeEvent.NavigatedToNextStep) - onNavigateToNextStep() - } - } - } HousingTypeScreen( uiState = uiState, navigateUp = navigateUp, @@ -195,18 +181,15 @@ private class StartUiStateProvider : CollectionPreviewParameterProvider()) +@AssistedInject internal class HousingTypeViewModel( + @Assisted moveIntentId: String, movingFlowRepository: MovingFlowRepository, + backstack: Backstack, ) : MoleculeViewModel( HousingTypeUiState.Loading, - HousingTypePresenter(movingFlowRepository), - ) + HousingTypePresenter(moveIntentId, movingFlowRepository, backstack), + ) { + @AssistedFactory + @ManualViewModelAssistedFactoryKey + @ContributesIntoMap(AppScope::class) + fun interface Factory : ManualViewModelAssistedFactory { + fun create( + @Assisted moveIntentId: String, + ): HousingTypeViewModel + } +} private class HousingTypePresenter( + private val moveIntentId: String, private val movingFlowRepository: MovingFlowRepository, + private val backstack: Backstack, ) : MoleculePresenter { @Suppress("NAME_SHADOWING") @Composable @@ -58,11 +72,6 @@ private class HousingTypePresenter( submittingHousingType = state.selectedHousingType } - NavigatedToNextStep -> { - val state = currentState as? HousingTypeUiState.Content ?: return@CollectEvents - currentState = state.copy(navigateToNextStep = false) - } - DismissStartError -> { loadIteration++ } @@ -77,7 +86,6 @@ private class HousingTypePresenter( currentState = HousingTypeUiState.Content( possibleHousingTypes = HousingType.entries, selectedHousingType = HousingType.entries.first(), - navigateToNextStep = false, ) submittingHousingType = null } @@ -91,7 +99,7 @@ private class HousingTypePresenter( currentState = state.copy(buttonLoading = true) movingFlowRepository.updateWithHousingType(submittingHousingTypeValue) submittingHousingType = null - currentState = state.copy(navigateToNextStep = true, buttonLoading = false) + backstack.add(EnterNewAddressKey(moveIntentId)) } } return currentState @@ -103,8 +111,6 @@ internal sealed interface HousingTypeEvent { data object SubmitHousingType : HousingTypeEvent - data object NavigatedToNextStep : HousingTypeEvent - data object DismissStartError : HousingTypeEvent } @@ -120,7 +126,6 @@ internal sealed interface HousingTypeUiState { data class Content( val possibleHousingTypes: List, val selectedHousingType: HousingType, - val navigateToNextStep: Boolean, val buttonLoading: Boolean = false, ) : HousingTypeUiState } diff --git a/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/ui/summary/SummaryDestination.kt b/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/ui/summary/SummaryDestination.kt index 5375771eff..9a7140c9c9 100644 --- a/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/ui/summary/SummaryDestination.kt +++ b/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/ui/summary/SummaryDestination.kt @@ -26,7 +26,6 @@ import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -112,14 +111,8 @@ internal fun SummaryDestination( navigateUp: () -> Unit, navigateBack: () -> Unit, exitFlow: () -> Unit, - onNavigateToFinishedScreen: (LocalDate) -> Unit, ) { val uiState = viewModel.uiState.collectAsStateWithLifecycle().value - if (uiState is SummaryUiState.Content && uiState.navigateToFinishedScreenWithDate != null) { - LaunchedEffect(uiState.navigateToFinishedScreenWithDate) { - onNavigateToFinishedScreen(uiState.navigateToFinishedScreenWithDate) - } - } SummaryScreen( uiState = uiState, navigateUp = navigateUp, @@ -509,7 +502,6 @@ private class SummaryUiStateProvider : PreviewParameterProvider ), isSubmitting = false, submitError = null, - navigateToFinishedScreenWithDate = null, moveIntentCost = MoveIntentCost( monthlyNet = UiMoney(199.0, SEK), monthlyGross = UiMoney(249.0, SEK), diff --git a/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/ui/summary/SummaryViewModel.kt b/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/ui/summary/SummaryViewModel.kt index 16f3b2f261..181f119aee 100644 --- a/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/ui/summary/SummaryViewModel.kt +++ b/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/ui/summary/SummaryViewModel.kt @@ -16,6 +16,9 @@ import com.hedvig.android.core.uidata.UiMoney import com.hedvig.android.data.contract.ContractGroup import com.hedvig.android.data.cross.sell.after.flow.CrossSellAfterFlowRepository import com.hedvig.android.data.cross.sell.after.flow.CrossSellInfoType +import com.hedvig.android.feature.movingflow.HousingTypeKey +import com.hedvig.android.feature.movingflow.SelectContractForMovingKey +import com.hedvig.android.feature.movingflow.SuccessfulMoveKey import com.hedvig.android.feature.movingflow.SummaryKey import com.hedvig.android.feature.movingflow.data.AddonId import com.hedvig.android.feature.movingflow.data.MovingFlowQuotes @@ -33,6 +36,9 @@ import com.hedvig.android.logger.logcat import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel +import com.hedvig.android.navigation.compose.Backstack +import com.hedvig.android.navigation.compose.navigateAndPopUpTo +import com.hedvig.android.navigation.compose.popUpTo import com.hedvig.ui.tiersandaddons.CostBreakdownEntry import com.hedvig.ui.tiersandaddons.DisplayDocument import dev.zacsweers.metro.Assisted @@ -51,6 +57,7 @@ internal class SummaryViewModel( apolloClient: ApolloClient, crossSellAfterFlowRepository: CrossSellAfterFlowRepository, getMoveIntentCostUseCase: GetMoveIntentCostUseCase, + backstack: Backstack, ) : MoleculeViewModel( Loading, SummaryPresenter( @@ -59,6 +66,7 @@ internal class SummaryViewModel( apolloClient = apolloClient, crossSellAfterFlowRepository = crossSellAfterFlowRepository, getMoveIntentCostUseCase = getMoveIntentCostUseCase, + backstack = backstack, ), ) { @AssistedFactory @@ -77,6 +85,7 @@ internal class SummaryPresenter( private val apolloClient: ApolloClient, private val crossSellAfterFlowRepository: CrossSellAfterFlowRepository, private val getMoveIntentCostUseCase: GetMoveIntentCostUseCase, + private val backstack: Backstack, ) : MoleculePresenter { @Composable override fun MoleculePresenterScope.present(lastState: SummaryUiState): SummaryUiState { @@ -84,7 +93,6 @@ internal class SummaryPresenter( var moveIntentCost: MoveIntentCost? by remember { mutableStateOf(null) } var submitChangesError: SubmitError? by remember { mutableStateOf(null) } var submitChangesWithData: SubmitChangesData? by remember { mutableStateOf(null) } - var navigateToFinishedScreenWithDate: LocalDate? by remember { mutableStateOf(null) } CollectEvents { event -> when (event) { @@ -181,10 +189,12 @@ internal class SummaryPresenter( crossSellAfterFlowRepository.completedCrossSellTriggeringSelfServiceSuccessfully( CrossSellInfoType.MovingFlow, ) - Snapshot.withMutableSnapshot { - submitChangesWithData = null - navigateToFinishedScreenWithDate = submitChangesDataValue.forDate - } + submitChangesWithData = null + backstack.popUpTo(inclusive = true) + backstack.navigateAndPopUpTo( + SuccessfulMoveKey(submitChangesDataValue.forDate), + inclusive = true, + ) } }, ) @@ -204,7 +214,6 @@ internal class SummaryPresenter( summaryInfo = summaryInfoValue.summaryInfo, isSubmitting = submitChangesWithData != null, submitError = submitChangesError, - navigateToFinishedScreenWithDate = navigateToFinishedScreenWithDate, moveIntentCost = moveIntentCost, ) } @@ -232,7 +241,6 @@ internal sealed interface SummaryUiState { private val summaryInfo: SummaryInfo, val isSubmitting: Boolean, val submitError: SubmitError?, - val navigateToFinishedScreenWithDate: LocalDate?, private val moveIntentCost: MoveIntentCost?, ) : SummaryUiState { val cards: List = buildList { @@ -251,8 +259,7 @@ internal sealed interface SummaryUiState { val totalPremium: UiMoney? = moveIntentCost?.monthlyNet val grossPremium: UiMoney? = moveIntentCost?.monthlyGross val shouldDisableInput: Boolean = isSubmitting || - submitError != null || - navigateToFinishedScreenWithDate != null + submitError != null sealed interface SubmitError { data object Generic : SubmitError diff --git a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/navigation/PaymentsEntries.kt b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/navigation/PaymentsEntries.kt index 0f7a4d3d99..c768c311d3 100644 --- a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/navigation/PaymentsEntries.kt +++ b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/navigation/PaymentsEntries.kt @@ -20,7 +20,6 @@ import com.hedvig.android.navigation.common.HedvigNavKey import com.hedvig.android.navigation.compose.Backstack import com.hedvig.android.navigation.compose.NavSuiteSceneDecoratorStrategy import com.hedvig.android.navigation.compose.add -import com.hedvig.android.navigation.compose.navigateAndPopUpTo import com.hedvig.android.shared.foreverui.ui.ui.ForeverDestination import com.hedvig.android.shared.foreverui.ui.ui.ForeverViewModel import dev.zacsweers.metrox.viewmodel.assistedMetroViewModel @@ -64,14 +63,6 @@ fun EntryProviderScope.paymentsEntries( onNavigateToPaymentDetails = dropUnlessResumed { chargeId: String -> backstack.add(PaymentDetailsKey(chargeId)) }, - onNavigateToSuccess = { showCancellationWarning -> - backstack.navigateAndPopUpTo( - ManualChargeSuccessKey( - showCancellationWarning = showCancellationWarning, - ), - inclusive = true, - ) - }, openConversation = openConversation, ) } diff --git a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/manualcharge/ManualChargeDestination.kt b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/manualcharge/ManualChargeDestination.kt index c9786597f1..ffda4db0cc 100644 --- a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/manualcharge/ManualChargeDestination.kt +++ b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/manualcharge/ManualChargeDestination.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -68,7 +67,6 @@ internal fun ManualChargeDestination( viewModel: ManualChargeViewModel, navigateUp: () -> Unit, onNavigateToPaymentDetails: (chargeId: String) -> Unit, - onNavigateToSuccess: (Boolean) -> Unit, openConversation: () -> Unit, ) { val uiState = viewModel.uiState.collectAsStateWithLifecycle() @@ -78,10 +76,6 @@ internal fun ManualChargeDestination( navigateUp = navigateUp, reload = { viewModel.emit(ManualChargeEvent.Retry) }, onNavigateToPaymentDetails = onNavigateToPaymentDetails, - onNavigateToSuccess = { showCancellationWarning -> - viewModel.emit(ManualChargeEvent.ClearNav) - onNavigateToSuccess(showCancellationWarning) - }, onTriggerPayment = { viewModel.emit(ManualChargeEvent.TriggerCharge) }, @@ -96,7 +90,6 @@ private fun ManualChargeScreen( reload: () -> Unit, openConversation: () -> Unit, onNavigateToPaymentDetails: (chargeId: String) -> Unit, - onNavigateToSuccess: (Boolean) -> Unit, onTriggerPayment: () -> Unit, ) { HedvigScaffold( @@ -140,17 +133,11 @@ private fun ManualChargeScreen( } is ManualChargeUiState.Success -> { - if (uiState.navigateToSuccess != null) { - LaunchedEffect(uiState.navigateToSuccess) { - onNavigateToSuccess(uiState.manualChargeInfo.showCancellationWarning) - } - } else { - ManualChargeSuccessScreen( - uiState, - onNavigateToPaymentDetails = onNavigateToPaymentDetails, - onTriggerPayment = onTriggerPayment, - ) - } + ManualChargeSuccessScreen( + uiState, + onNavigateToPaymentDetails = onNavigateToPaymentDetails, + onTriggerPayment = onTriggerPayment, + ) } } } @@ -339,14 +326,12 @@ private fun ManualChargeScreenSuccessPreview( bankAccountDisplayValue = "Swedbank", showCancellationWarning = showCancellationWarning, ), - navigateToSuccess = null, ), navigateUp = {}, reload = {}, {}, {}, {}, - {}, ) } } @@ -365,7 +350,6 @@ private fun ManualChargeScreenLoadingPreview() { {}, {}, {}, - {}, ) } } @@ -397,7 +381,6 @@ private fun ManualChargeScreenFailurePreview( {}, {}, {}, - {}, ) } } diff --git a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/manualcharge/ManualChargeViewModel.kt b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/manualcharge/ManualChargeViewModel.kt index 9d409c9647..33e1580ac8 100644 --- a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/manualcharge/ManualChargeViewModel.kt +++ b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/manualcharge/ManualChargeViewModel.kt @@ -13,9 +13,13 @@ import com.hedvig.android.core.common.di.AppScope import com.hedvig.android.feature.payments.data.GetManualChargeInfoUseCase import com.hedvig.android.feature.payments.data.ManualChargeInfo import com.hedvig.android.feature.payments.data.TriggerManualChargeUseCase +import com.hedvig.android.feature.payments.navigation.ManualChargeKey +import com.hedvig.android.feature.payments.navigation.ManualChargeSuccessKey import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel +import com.hedvig.android.navigation.compose.Backstack +import com.hedvig.android.navigation.compose.navigateAndPopUpTo import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject import dev.zacsweers.metro.binding @@ -27,14 +31,16 @@ import dev.zacsweers.metrox.viewmodel.ViewModelKey internal class ManualChargeViewModel( getManualChargeInfoUseCase: GetManualChargeInfoUseCase, triggerManualCharge: TriggerManualChargeUseCase, + backstack: Backstack, ) : MoleculeViewModel( initialState = ManualChargeUiState.Loading, - presenter = ManualChargePresenter(getManualChargeInfoUseCase, triggerManualCharge), + presenter = ManualChargePresenter(getManualChargeInfoUseCase, triggerManualCharge, backstack), ) private class ManualChargePresenter( private val getManualChargeInfoUseCase: GetManualChargeInfoUseCase, private val triggerManualCharge: TriggerManualChargeUseCase, + private val backstack: Backstack, ) : MoleculePresenter { @Composable override fun MoleculePresenterScope.present(lastState: ManualChargeUiState): ManualChargeUiState { @@ -51,11 +57,6 @@ private class ManualChargePresenter( ManualChargeEvent.TriggerCharge -> { triggerChargeIteration++ } - - ManualChargeEvent.ClearNav -> { - val currentState = screenState as? ManualChargeUiState.Success ?: return@CollectEvents - screenState = currentState.copy(navigateToSuccess = null) - } } } @@ -68,9 +69,11 @@ private class ManualChargePresenter( screenState = ManualChargeUiState.Failure(it) }, ifRight = { - screenState = ManualChargeUiState.Success( - manualChargeInfo = currentState.manualChargeInfo, - navigateToSuccess = Unit, + backstack.navigateAndPopUpTo( + ManualChargeSuccessKey( + showCancellationWarning = currentState.manualChargeInfo.showCancellationWarning, + ), + inclusive = true, ) }, ) @@ -83,7 +86,6 @@ private class ManualChargePresenter( ifRight = { manualChargeInfo -> screenState = ManualChargeUiState.Success( manualChargeInfo = manualChargeInfo, - null, ) }, ifLeft = { failure -> @@ -104,7 +106,6 @@ internal sealed interface ManualChargeUiState { data class Success( val manualChargeInfo: ManualChargeInfo, - val navigateToSuccess: Unit?, val payButtonLoading: Boolean = false, ) : ManualChargeUiState } @@ -113,6 +114,4 @@ internal sealed interface ManualChargeEvent { data object Retry : ManualChargeEvent data object TriggerCharge : ManualChargeEvent - - data object ClearNav : ManualChargeEvent } diff --git a/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/navigation/PayoutAccountEntries.kt b/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/navigation/PayoutAccountEntries.kt index 6909fa9f08..554d0f6d53 100644 --- a/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/navigation/PayoutAccountEntries.kt +++ b/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/navigation/PayoutAccountEntries.kt @@ -65,9 +65,6 @@ fun EntryProviderScope.payoutAccountEntries( EditBankAccountDestination( viewModel = viewModel, globalSnackBarState = globalSnackBarState, - onSuccessfullyConnected = { - backstack.popUpTo(inclusive = true) - }, navigateUp = backstack::navigateUp, ) } @@ -77,9 +74,6 @@ fun EntryProviderScope.payoutAccountEntries( SetupSwishPayoutDestination( viewModel = viewModel, globalSnackBarState = globalSnackBarState, - onSuccessfullyConnected = { - backstack.popUpTo(inclusive = true) - }, navigateUp = backstack::navigateUp, ) } @@ -89,9 +83,6 @@ fun EntryProviderScope.payoutAccountEntries( SetupInvoicePayoutDestination( viewModel = viewModel, globalSnackBarState = globalSnackBarState, - onSuccessfullyConnected = { - backstack.popUpTo(inclusive = true) - }, navigateUp = backstack::navigateUp, ) } diff --git a/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/editbankaccount/EditBankAccountDestination.kt b/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/editbankaccount/EditBankAccountDestination.kt index b4ccaa759d..ec456c6384 100644 --- a/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/editbankaccount/EditBankAccountDestination.kt +++ b/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/editbankaccount/EditBankAccountDestination.kt @@ -37,7 +37,6 @@ import org.jetbrains.compose.resources.stringResource internal fun EditBankAccountDestination( viewModel: EditBankAccountViewModel, globalSnackBarState: GlobalSnackBarState, - onSuccessfullyConnected: () -> Unit, navigateUp: () -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -45,10 +44,7 @@ internal fun EditBankAccountDestination( uiState = uiState, globalSnackBarState = globalSnackBarState, onSave = { viewModel.emit(EditBankAccountEvent.Save) }, - showedSnackBar = { - viewModel.emit(EditBankAccountEvent.ShowedSnackBar) - onSuccessfullyConnected() - }, + showedSnackBar = { viewModel.emit(EditBankAccountEvent.ShowedSnackBar) }, navigateUp = navigateUp, ) } diff --git a/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/editbankaccount/EditBankAccountViewModel.kt b/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/editbankaccount/EditBankAccountViewModel.kt index 072055d3b5..a407b8770b 100644 --- a/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/editbankaccount/EditBankAccountViewModel.kt +++ b/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/editbankaccount/EditBankAccountViewModel.kt @@ -18,9 +18,12 @@ import androidx.lifecycle.ViewModel import com.hedvig.android.core.common.di.AppScope import com.hedvig.android.feature.payoutaccount.data.SetupNordeaPayoutUseCase import com.hedvig.android.feature.payoutaccount.data.bankNameForClearingNumber +import com.hedvig.android.feature.payoutaccount.navigation.SelectPayoutMethodKey import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel +import com.hedvig.android.navigation.compose.Backstack +import com.hedvig.android.navigation.compose.popUpTo import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject import dev.zacsweers.metro.binding @@ -31,6 +34,7 @@ import dev.zacsweers.metrox.viewmodel.ViewModelKey @ContributesIntoMap(AppScope::class, binding()) internal class EditBankAccountViewModel( setupNordeaPayoutUseCase: SetupNordeaPayoutUseCase, + backstack: Backstack, ) : MoleculeViewModel( EditBankAccountUiState( accountNumberState = TextFieldState(), @@ -39,7 +43,7 @@ internal class EditBankAccountViewModel( errorMessage = null, showSuccessSnackBar = false, ), - EditBankAccountPresenter(setupNordeaPayoutUseCase), + EditBankAccountPresenter(setupNordeaPayoutUseCase, backstack), ) internal sealed interface EditBankAccountEvent { @@ -64,6 +68,7 @@ internal data class EditBankAccountUiState( internal class EditBankAccountPresenter( private val setupNordeaPayoutUseCase: SetupNordeaPayoutUseCase, + private val backstack: Backstack, ) : MoleculePresenter { @Composable override fun MoleculePresenterScope.present( @@ -111,7 +116,7 @@ internal class EditBankAccountPresenter( } EditBankAccountEvent.ShowedSnackBar -> { - showSuccessSnackBar = false + backstack.popUpTo(inclusive = true) } } } diff --git a/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/setupinvoice/SetupInvoicePayoutDestination.kt b/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/setupinvoice/SetupInvoicePayoutDestination.kt index b73f09e543..6443ba3b0b 100644 --- a/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/setupinvoice/SetupInvoicePayoutDestination.kt +++ b/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/setupinvoice/SetupInvoicePayoutDestination.kt @@ -28,7 +28,6 @@ import org.jetbrains.compose.resources.stringResource internal fun SetupInvoicePayoutDestination( viewModel: SetupInvoicePayoutViewModel, globalSnackBarState: GlobalSnackBarState, - onSuccessfullyConnected: () -> Unit, navigateUp: () -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -36,10 +35,7 @@ internal fun SetupInvoicePayoutDestination( uiState = uiState, globalSnackBarState = globalSnackBarState, onConnect = { viewModel.emit(SetupInvoicePayoutEvent.Connect) }, - showedSnackBar = { - viewModel.emit(SetupInvoicePayoutEvent.ShowedSnackBar) - onSuccessfullyConnected() - }, + showedSnackBar = { viewModel.emit(SetupInvoicePayoutEvent.ShowedSnackBar) }, navigateUp = navigateUp, ) } diff --git a/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/setupinvoice/SetupInvoicePayoutViewModel.kt b/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/setupinvoice/SetupInvoicePayoutViewModel.kt index 93d5dd624b..d7e4ca5706 100644 --- a/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/setupinvoice/SetupInvoicePayoutViewModel.kt +++ b/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/setupinvoice/SetupInvoicePayoutViewModel.kt @@ -10,9 +10,12 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import com.hedvig.android.core.common.di.AppScope import com.hedvig.android.feature.payoutaccount.data.SetupInvoicePayoutUseCase +import com.hedvig.android.feature.payoutaccount.navigation.SelectPayoutMethodKey import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel +import com.hedvig.android.navigation.compose.Backstack +import com.hedvig.android.navigation.compose.popUpTo import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject import dev.zacsweers.metro.binding @@ -23,9 +26,10 @@ import dev.zacsweers.metrox.viewmodel.ViewModelKey @ContributesIntoMap(AppScope::class, binding()) internal class SetupInvoicePayoutViewModel( setupInvoicePayoutUseCase: SetupInvoicePayoutUseCase, + backstack: Backstack, ) : MoleculeViewModel( SetupInvoicePayoutUiState(false, null, false), - SetupInvoicePayoutPresenter(setupInvoicePayoutUseCase), + SetupInvoicePayoutPresenter(setupInvoicePayoutUseCase, backstack), ) internal sealed interface SetupInvoicePayoutEvent { @@ -42,6 +46,7 @@ internal data class SetupInvoicePayoutUiState( internal class SetupInvoicePayoutPresenter( private val setupInvoicePayoutUseCase: SetupInvoicePayoutUseCase, + private val backstack: Backstack, ) : MoleculePresenter { @Composable override fun MoleculePresenterScope.present( @@ -82,7 +87,7 @@ internal class SetupInvoicePayoutPresenter( } SetupInvoicePayoutEvent.ShowedSnackBar -> { - showSuccessSnackBar = false + backstack.popUpTo(inclusive = true) } } } diff --git a/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/setupswish/SetupSwishPayoutDestination.kt b/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/setupswish/SetupSwishPayoutDestination.kt index f20e9a0b66..b6e1168478 100644 --- a/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/setupswish/SetupSwishPayoutDestination.kt +++ b/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/setupswish/SetupSwishPayoutDestination.kt @@ -36,7 +36,6 @@ import org.jetbrains.compose.resources.stringResource internal fun SetupSwishPayoutDestination( viewModel: SetupSwishPayoutViewModel, globalSnackBarState: GlobalSnackBarState, - onSuccessfullyConnected: () -> Unit, navigateUp: () -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -44,10 +43,7 @@ internal fun SetupSwishPayoutDestination( uiState = uiState, globalSnackBarState = globalSnackBarState, onSave = { viewModel.emit(SetupSwishPayoutEvent.Save) }, - showedSnackBar = { - viewModel.emit(SetupSwishPayoutEvent.ShowedSnackBar) - onSuccessfullyConnected() - }, + showedSnackBar = { viewModel.emit(SetupSwishPayoutEvent.ShowedSnackBar) }, navigateUp = navigateUp, ) } diff --git a/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/setupswish/SetupSwishPayoutViewModel.kt b/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/setupswish/SetupSwishPayoutViewModel.kt index 1c53259afb..56440d9494 100644 --- a/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/setupswish/SetupSwishPayoutViewModel.kt +++ b/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/setupswish/SetupSwishPayoutViewModel.kt @@ -11,9 +11,12 @@ import androidx.lifecycle.ViewModel import com.hedvig.android.core.common.ErrorMessage import com.hedvig.android.core.common.di.AppScope import com.hedvig.android.feature.payoutaccount.data.SetupSwishPayoutUseCase +import com.hedvig.android.feature.payoutaccount.navigation.SelectPayoutMethodKey import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel +import com.hedvig.android.navigation.compose.Backstack +import com.hedvig.android.navigation.compose.popUpTo import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject import dev.zacsweers.metro.binding @@ -24,9 +27,10 @@ import dev.zacsweers.metrox.viewmodel.ViewModelKey @ContributesIntoMap(AppScope::class, binding()) internal class SetupSwishPayoutViewModel( setupSwishPayoutUseCase: SetupSwishPayoutUseCase, + backstack: Backstack, ) : MoleculeViewModel( SetupSwishPayoutUiState(TextFieldState(), false, null, false), - SetupSwishPayoutPresenter(setupSwishPayoutUseCase), + SetupSwishPayoutPresenter(setupSwishPayoutUseCase, backstack), ) internal sealed interface SetupSwishPayoutEvent { @@ -44,6 +48,7 @@ internal data class SetupSwishPayoutUiState( internal class SetupSwishPayoutPresenter( private val setupSwishPayoutUseCase: SetupSwishPayoutUseCase, + private val backstack: Backstack, ) : MoleculePresenter { @Composable override fun MoleculePresenterScope.present( @@ -84,7 +89,7 @@ internal class SetupSwishPayoutPresenter( } SetupSwishPayoutEvent.ShowedSnackBar -> { - showSuccessSnackBar = false + backstack.popUpTo(inclusive = true) } } } diff --git a/app/feature/feature-remove-addons/build.gradle.kts b/app/feature/feature-remove-addons/build.gradle.kts index 04023033b7..29a10bf6a9 100644 --- a/app/feature/feature-remove-addons/build.gradle.kts +++ b/app/feature/feature-remove-addons/build.gradle.kts @@ -39,14 +39,14 @@ kotlin { implementation(projects.moleculePublic) implementation(projects.uiTiersAndAddons) implementation(projects.dataProductVariantPublic) + implementation(projects.navigationCommon) + implementation(projects.navigationCompose) } androidMain.dependencies { api(libs.androidx.navigation.common) implementation(libs.androidx.navigation.compose) implementation(libs.bundles.kmpPreviewBugWorkaround) implementation(projects.composeUi) - implementation(projects.navigationCommon) - implementation(projects.navigationCompose) implementation(projects.navigationCore) implementation(projects.dataProductVariantPublic) } diff --git a/app/feature/feature-remove-addons/src/androidMain/kotlin/com/hedvig/feature/remove/addons/RemoveAddonsEntries.kt b/app/feature/feature-remove-addons/src/androidMain/kotlin/com/hedvig/feature/remove/addons/RemoveAddonsEntries.kt index 6c528d83ad..f23ed0453d 100644 --- a/app/feature/feature-remove-addons/src/androidMain/kotlin/com/hedvig/feature/remove/addons/RemoveAddonsEntries.kt +++ b/app/feature/feature-remove-addons/src/androidMain/kotlin/com/hedvig/feature/remove/addons/RemoveAddonsEntries.kt @@ -2,59 +2,15 @@ package com.hedvig.feature.remove.addons import androidx.compose.runtime.LaunchedEffect import androidx.navigation3.runtime.EntryProviderScope -import com.hedvig.android.core.uidata.ItemCost -import com.hedvig.android.data.contract.ContractId -import com.hedvig.android.data.productvariant.AddonVariant -import com.hedvig.android.data.productvariant.ProductVariant import com.hedvig.android.navigation.common.HedvigNavKey import com.hedvig.android.navigation.compose.Backstack -import com.hedvig.android.navigation.compose.add -import com.hedvig.android.navigation.compose.findLastOrNull import com.hedvig.android.navigation.compose.navigateAndPopUpTo import com.hedvig.android.navigation.compose.popBackstack -import com.hedvig.android.navigation.compose.popUpTo -import com.hedvig.feature.remove.addons.data.CurrentlyActiveAddon import com.hedvig.feature.remove.addons.ui.RemoveAddonFailureScreen import com.hedvig.feature.remove.addons.ui.RemoveAddonSuccessScreen import com.hedvig.feature.remove.addons.ui.RemoveAddonSummaryDestination import com.hedvig.feature.remove.addons.ui.SelectAddonToRemoveDestination import com.hedvig.feature.remove.addons.ui.SelectInsuranceToRemoveAddonDestination -import kotlinx.datetime.LocalDate -import kotlinx.serialization.Serializable - -@Serializable -internal data class SummaryParameters( - val contractId: ContractId, - val addonsToRemove: List, - val activationDate: LocalDate, - val baseCost: ItemCost, - val currentTotalCost: ItemCost, - val productVariant: ProductVariant, - val existingAddons: List, -) - -@Serializable -data class RemoveAddonsKey( - val insuranceId: ContractId?, - val preselectedAddonVariant: AddonVariant?, -) : HedvigNavKey - -@Serializable -internal data class ChooseAddonToRemoveKey( - val insuranceId: ContractId, - val preselectedAddonVariant: AddonVariant?, -) : HedvigNavKey - -@Serializable -internal data class RemoveAddonSummaryKey( - val params: SummaryParameters, -) : HedvigNavKey - -@Serializable -internal data class RemoveAddonSubmitSuccessKey(val activationDate: LocalDate) : HedvigNavKey - -@Serializable -internal data object RemoveAddonSubmitFailureKey : HedvigNavKey fun EntryProviderScope.removeAddonsEntries(backstack: Backstack) { // Flow anchor / insurance picker. When seeded with an insuranceId it jumps straight to the addon @@ -75,14 +31,6 @@ fun EntryProviderScope.removeAddonsEntries(backstack: Backstack) { } else { SelectInsuranceToRemoveAddonDestination( navigateUp = backstack::navigateUp, - navigateToChooseAddon = { contractId -> - backstack.add( - ChooseAddonToRemoveKey( - insuranceId = contractId, - preselectedAddonVariant = null, - ), - ) - }, ) } } @@ -92,51 +40,16 @@ fun EntryProviderScope.removeAddonsEntries(backstack: Backstack) { contractId = key.insuranceId, preselectedAddonProduct = key.preselectedAddonVariant, navigateUp = backstack::navigateUp, - navigateToSummary = { - contractId: ContractId, - addons: List, - activationDate: LocalDate, - baseCost: ItemCost, - currentCost: ItemCost, - productVariant: ProductVariant, - allAddons: List, - popDestination: Boolean, - -> - val summary = RemoveAddonSummaryKey( - params = SummaryParameters( - contractId = contractId, - addonsToRemove = addons, - activationDate = activationDate, - baseCost = baseCost, - currentTotalCost = currentCost, - productVariant = productVariant, - existingAddons = allAddons, - ), - ) - if (popDestination) { - backstack.navigateAndPopUpTo(summary, inclusive = true) - } else { - backstack.add(summary) - } - }, ) } entry { key -> RemoveAddonSummaryDestination( - navigateToSuccess = { - backstack.navigateExitingRemoveAddonFlow( - RemoveAddonSubmitSuccessKey(key.params.activationDate), - ) - }, contractId = key.params.contractId, addonsToRemove = key.params.addonsToRemove, activationDate = key.params.activationDate, baseCost = key.params.baseCost, currentTotalCost = key.params.currentTotalCost, - onFailure = { - backstack.add(RemoveAddonSubmitFailureKey) - }, onCloseFlow = { backstack.popUpToRemoveAddonAnchor(inclusive = true) }, @@ -159,21 +72,3 @@ fun EntryProviderScope.removeAddonsEntries(backstack: Backstack) { ) } } - -/** - * The flow's exit anchor: [RemoveAddonsKey] when the insurance picker was shown, or - * [ChooseAddonToRemoveKey] when the flow was seeded with an insuranceId (the anchor having popped - * itself during the jump to the addon picker). - */ -private fun Backstack.popUpToRemoveAddonAnchor(inclusive: Boolean) { - if (findLastOrNull() != null) { - popUpTo(inclusive) - } else { - popUpTo(inclusive) - } -} - -private fun Backstack.navigateExitingRemoveAddonFlow(destination: HedvigNavKey) { - popUpToRemoveAddonAnchor(inclusive = true) - add(destination) -} diff --git a/app/feature/feature-remove-addons/src/commonMain/kotlin/com/hedvig/feature/remove/addons/RemoveAddonsNavKeys.kt b/app/feature/feature-remove-addons/src/commonMain/kotlin/com/hedvig/feature/remove/addons/RemoveAddonsNavKeys.kt new file mode 100644 index 0000000000..b0ece81311 --- /dev/null +++ b/app/feature/feature-remove-addons/src/commonMain/kotlin/com/hedvig/feature/remove/addons/RemoveAddonsNavKeys.kt @@ -0,0 +1,66 @@ +package com.hedvig.feature.remove.addons + +import com.hedvig.android.core.uidata.ItemCost +import com.hedvig.android.data.contract.ContractId +import com.hedvig.android.data.productvariant.AddonVariant +import com.hedvig.android.data.productvariant.ProductVariant +import com.hedvig.android.navigation.common.HedvigNavKey +import com.hedvig.android.navigation.compose.Backstack +import com.hedvig.android.navigation.compose.add +import com.hedvig.android.navigation.compose.findLastOrNull +import com.hedvig.android.navigation.compose.popUpTo +import com.hedvig.feature.remove.addons.data.CurrentlyActiveAddon +import kotlinx.datetime.LocalDate +import kotlinx.serialization.Serializable + +@Serializable +internal data class SummaryParameters( + val contractId: ContractId, + val addonsToRemove: List, + val activationDate: LocalDate, + val baseCost: ItemCost, + val currentTotalCost: ItemCost, + val productVariant: ProductVariant, + val existingAddons: List, +) + +@Serializable +data class RemoveAddonsKey( + val insuranceId: ContractId?, + val preselectedAddonVariant: AddonVariant?, +) : HedvigNavKey + +@Serializable +internal data class ChooseAddonToRemoveKey( + val insuranceId: ContractId, + val preselectedAddonVariant: AddonVariant?, +) : HedvigNavKey + +@Serializable +internal data class RemoveAddonSummaryKey( + val params: SummaryParameters, +) : HedvigNavKey + +@Serializable +internal data class RemoveAddonSubmitSuccessKey(val activationDate: LocalDate) : HedvigNavKey + +@Serializable +internal data object RemoveAddonSubmitFailureKey : HedvigNavKey + +/** + * The flow's exit anchor: [RemoveAddonsKey] when the insurance picker was shown, or + * [ChooseAddonToRemoveKey] when the flow was seeded with an insuranceId (the anchor having popped + * itself during the jump to the addon picker). + */ +internal fun Backstack.popUpToRemoveAddonAnchor(inclusive: Boolean) { + if (findLastOrNull() != null) { + popUpTo(inclusive) + } else { + popUpTo(inclusive) + } +} + +internal fun Backstack.navigateExitingRemoveAddonFlow(destination: HedvigNavKey) { + popUpToRemoveAddonAnchor(inclusive = true) + add(destination) +} diff --git a/app/feature/feature-remove-addons/src/commonMain/kotlin/com/hedvig/feature/remove/addons/ui/RemoveAddonSummaryDestination.kt b/app/feature/feature-remove-addons/src/commonMain/kotlin/com/hedvig/feature/remove/addons/ui/RemoveAddonSummaryDestination.kt index 30ada21b25..5960a3df34 100644 --- a/app/feature/feature-remove-addons/src/commonMain/kotlin/com/hedvig/feature/remove/addons/ui/RemoveAddonSummaryDestination.kt +++ b/app/feature/feature-remove-addons/src/commonMain/kotlin/com/hedvig/feature/remove/addons/ui/RemoveAddonSummaryDestination.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -38,6 +37,7 @@ import com.hedvig.android.design.system.hedvig.HedvigScaffold import com.hedvig.android.design.system.hedvig.HedvigTheme import com.hedvig.android.design.system.hedvig.Surface import com.hedvig.android.design.system.hedvig.datepicker.getLocale +import com.hedvig.feature.remove.addons.SummaryParameters import com.hedvig.feature.remove.addons.data.CurrentlyActiveAddon import com.hedvig.ui.tiersandaddons.CostBreakdownEntry import com.hedvig.ui.tiersandaddons.DisplayDocument @@ -68,15 +68,13 @@ internal fun RemoveAddonSummaryDestination( currentTotalCost: ItemCost, existingAddonsToRemove: List, productVariant: ProductVariant, - navigateToSuccess: (activationDate: LocalDate) -> Unit, navigateUp: () -> Unit, - onFailure: () -> Unit, onCloseFlow: () -> Unit, ) { val viewModel: RemoveAddonSummaryViewModel = assistedMetroViewModel { create( - CommonSummaryParameters( + SummaryParameters( contractId = contractId, addonsToRemove = addonsToRemove, activationDate = activationDate, @@ -90,14 +88,6 @@ internal fun RemoveAddonSummaryDestination( val uiState by viewModel.uiState.collectAsStateWithLifecycle() RemoveAddonSummaryScreen( uiState = uiState, - onSuccess = { date -> - viewModel.emit(RemoveAddonSummaryEvent.ReturnToInitialState) - navigateToSuccess(date) - }, - onFailure = { - viewModel.emit(RemoveAddonSummaryEvent.ReturnToInitialState) - onFailure() - }, navigateUp = navigateUp, onSubmitQuoteClick = { viewModel.emit(RemoveAddonSummaryEvent.Submit) @@ -112,31 +102,17 @@ internal fun RemoveAddonSummaryDestination( @Composable private fun RemoveAddonSummaryScreen( uiState: RemoveAddonSummaryState, - onSuccess: (LocalDate) -> Unit, - onFailure: () -> Unit, navigateUp: () -> Unit, onSubmitQuoteClick: () -> Unit, onCloseFlow: () -> Unit, reload: () -> Unit, ) { when (uiState) { - is RemoveAddonSummaryState.Loading -> { - LaunchedEffect(uiState.activationDateToNavigateToSuccess) { - val date = uiState.activationDateToNavigateToSuccess - if (date != null) { - onSuccess(date) - } - } + RemoveAddonSummaryState.Loading -> { HedvigFullScreenCenterAlignedProgress() } is RemoveAddonSummaryState.Content -> { - LaunchedEffect(uiState.navigateToFailure) { - val fail = uiState.navigateToFailure - if (fail != null) { - onFailure() - } - } SummaryContentScreen( uiState = uiState, navigateUp = navigateUp, @@ -282,8 +258,6 @@ private fun PreviewRemoveAddonSummaryScreen( {}, {}, {}, - {}, - {}, ) } } @@ -292,10 +266,10 @@ private fun PreviewRemoveAddonSummaryScreen( private class RemoveAddonSummaryStateUiStateProvider : CollectionPreviewParameterProvider( listOf( - RemoveAddonSummaryState.Loading(), + RemoveAddonSummaryState.Loading, RemoveAddonSummaryState.Failure, RemoveAddonSummaryState.Content( - summaryParams = CommonSummaryParameters( + summaryParams = SummaryParameters( contractId = ContractId("contractId"), addonsToRemove = listOf( CurrentlyActiveAddon( @@ -376,7 +350,6 @@ private class RemoveAddonSummaryStateUiStateProvider : ), ), ), - navigateToFailure = null, exposureName = "exposureName", ), ), diff --git a/app/feature/feature-remove-addons/src/commonMain/kotlin/com/hedvig/feature/remove/addons/ui/RemoveAddonSummaryViewModel.kt b/app/feature/feature-remove-addons/src/commonMain/kotlin/com/hedvig/feature/remove/addons/ui/RemoveAddonSummaryViewModel.kt index 0dbf09f5b6..b0687a02ef 100644 --- a/app/feature/feature-remove-addons/src/commonMain/kotlin/com/hedvig/feature/remove/addons/ui/RemoveAddonSummaryViewModel.kt +++ b/app/feature/feature-remove-addons/src/commonMain/kotlin/com/hedvig/feature/remove/addons/ui/RemoveAddonSummaryViewModel.kt @@ -11,9 +11,15 @@ import com.hedvig.android.core.common.di.AppScope import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel +import com.hedvig.android.navigation.compose.Backstack +import com.hedvig.android.navigation.compose.add +import com.hedvig.feature.remove.addons.RemoveAddonSubmitFailureKey +import com.hedvig.feature.remove.addons.RemoveAddonSubmitSuccessKey +import com.hedvig.feature.remove.addons.SummaryParameters import com.hedvig.feature.remove.addons.data.GetAddonRemovalCostBreakdownUseCase import com.hedvig.feature.remove.addons.data.GetInsurancesWithRemovableAddonsUseCase import com.hedvig.feature.remove.addons.data.SubmitAddonRemovalUseCase +import com.hedvig.feature.remove.addons.navigateExitingRemoveAddonFlow import com.hedvig.ui.tiersandaddons.QuoteCostBreakdown import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory @@ -21,24 +27,25 @@ import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactory import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactoryKey -import kotlinx.datetime.LocalDate @AssistedInject internal class RemoveAddonSummaryViewModel( - @Assisted params: CommonSummaryParameters, + @Assisted params: SummaryParameters, submitAddonRemovalUseCase: SubmitAddonRemovalUseCase, getAddonRemovalCostBreakdownUseCase: GetAddonRemovalCostBreakdownUseCase, getInsurancesWithRemovableAddonsUseCase: GetInsurancesWithRemovableAddonsUseCase, + backstack: Backstack, ) : MoleculeViewModel< RemoveAddonSummaryEvent, RemoveAddonSummaryState, >( - initialState = RemoveAddonSummaryState.Loading(), + initialState = RemoveAddonSummaryState.Loading, presenter = RemoveAddonSummaryPresenter( submitAddonRemovalUseCase = submitAddonRemovalUseCase, params = params, getAddonRemovalCostBreakdownUseCase = getAddonRemovalCostBreakdownUseCase, getInsurancesWithRemovableAddonsUseCase = getInsurancesWithRemovableAddonsUseCase, + backstack = backstack, ), ) { @AssistedFactory @@ -46,16 +53,17 @@ internal class RemoveAddonSummaryViewModel( @ContributesIntoMap(AppScope::class) fun interface Factory : ManualViewModelAssistedFactory { fun create( - @Assisted params: CommonSummaryParameters, + @Assisted params: SummaryParameters, ): RemoveAddonSummaryViewModel } } private class RemoveAddonSummaryPresenter( private val submitAddonRemovalUseCase: SubmitAddonRemovalUseCase, - private val params: CommonSummaryParameters, + private val params: SummaryParameters, private val getAddonRemovalCostBreakdownUseCase: GetAddonRemovalCostBreakdownUseCase, private val getInsurancesWithRemovableAddonsUseCase: GetInsurancesWithRemovableAddonsUseCase, + private val backstack: Backstack, ) : MoleculePresenter< RemoveAddonSummaryEvent, RemoveAddonSummaryState, @@ -67,8 +75,6 @@ private class RemoveAddonSummaryPresenter( var currentState: RemoveAddonSummaryState by remember { mutableStateOf(lastState) } var submitIteration by remember { mutableIntStateOf(0) } var loadIteration by remember { mutableIntStateOf(0) } - var activationDateForNavigation by remember { mutableStateOf(null) } - var failureForNavigation by remember { mutableStateOf(null) } LaunchedEffect(loadIteration) { val exposureName = getInsurancesWithRemovableAddonsUseCase @@ -95,7 +101,6 @@ private class RemoveAddonSummaryPresenter( summaryParams = params, costBreakdown = result, exposureName = exposureName, - navigateToFailure = null, ) }, ) @@ -104,7 +109,7 @@ private class RemoveAddonSummaryPresenter( LaunchedEffect(submitIteration) { val state = currentState as? RemoveAddonSummaryState.Content ?: return@LaunchedEffect if (submitIteration > 0) { - currentState = RemoveAddonSummaryState.Loading() + currentState = RemoveAddonSummaryState.Loading submitAddonRemovalUseCase.invoke( params.contractId, params.addonsToRemove.map { @@ -112,11 +117,11 @@ private class RemoveAddonSummaryPresenter( }, ).fold( ifLeft = { - failureForNavigation = Unit currentState = state + backstack.add(RemoveAddonSubmitFailureKey) }, ifRight = { - activationDateForNavigation = params.activationDate + backstack.navigateExitingRemoveAddonFlow(RemoveAddonSubmitSuccessKey(params.activationDate)) }, ) } @@ -128,40 +133,24 @@ private class RemoveAddonSummaryPresenter( submitIteration++ } - is RemoveAddonSummaryEvent.ReturnToInitialState -> { - failureForNavigation = null - activationDateForNavigation = null - } - is RemoveAddonSummaryEvent.Retry -> { loadIteration++ } } } - return when (val state = currentState) { - is RemoveAddonSummaryState.Content -> state.copy( - navigateToFailure = failureForNavigation, - ) - - is RemoveAddonSummaryState.Loading -> state.copy(activationDateToNavigateToSuccess = activationDateForNavigation) - - RemoveAddonSummaryState.Failure -> state - } + return currentState } } internal sealed interface RemoveAddonSummaryState { data class Content( - val summaryParams: CommonSummaryParameters, + val summaryParams: SummaryParameters, val costBreakdown: QuoteCostBreakdown, val exposureName: String, - val navigateToFailure: Unit? = null, ) : RemoveAddonSummaryState - data class Loading( - val activationDateToNavigateToSuccess: LocalDate? = null, - ) : RemoveAddonSummaryState + data object Loading : RemoveAddonSummaryState data object Failure : RemoveAddonSummaryState } @@ -169,7 +158,5 @@ internal sealed interface RemoveAddonSummaryState { internal interface RemoveAddonSummaryEvent { data object Retry : RemoveAddonSummaryEvent - data object ReturnToInitialState : RemoveAddonSummaryEvent - data object Submit : RemoveAddonSummaryEvent } diff --git a/app/feature/feature-remove-addons/src/commonMain/kotlin/com/hedvig/feature/remove/addons/ui/SelectAddonToRemoveDestination.kt b/app/feature/feature-remove-addons/src/commonMain/kotlin/com/hedvig/feature/remove/addons/ui/SelectAddonToRemoveDestination.kt index 8417788bc2..fceeea3b9f 100644 --- a/app/feature/feature-remove-addons/src/commonMain/kotlin/com/hedvig/feature/remove/addons/ui/SelectAddonToRemoveDestination.kt +++ b/app/feature/feature-remove-addons/src/commonMain/kotlin/com/hedvig/feature/remove/addons/ui/SelectAddonToRemoveDestination.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -67,16 +66,6 @@ internal fun SelectAddonToRemoveDestination( contractId: ContractId, preselectedAddonProduct: AddonVariant?, navigateUp: () -> Unit, - navigateToSummary: ( - ContractId, - List, - LocalDate, - ItemCost, - ItemCost, - ProductVariant, - List, - Boolean, - ) -> Unit, ) { val viewModel: SelectAddonToRemoveViewModel = assistedMetroViewModel { @@ -89,19 +78,6 @@ internal fun SelectAddonToRemoveDestination( reload = { viewModel.emit(SelectAddonToRemoveEvent.Retry) }, - navigateToSummary = { params, popDestination -> - viewModel.emit(SelectAddonToRemoveEvent.ClearNavigation) - navigateToSummary( - params.contractId, - params.addonsToRemove, - params.activationDate, - params.baseCost, - params.currentTotalCost, - params.productVariant, - params.existingAddons, - popDestination, - ) - }, onSubmit = { viewModel.emit(SelectAddonToRemoveEvent.Submit) }, @@ -118,10 +94,6 @@ private fun SelectAddonToRemoveScreen( reload: () -> Unit, onSubmit: () -> Unit, onToggleOption: (CurrentlyActiveAddon) -> Unit, - navigateToSummary: ( - params: CommonSummaryParameters, - popThisDestination: Boolean, - ) -> Unit, ) { when (uiState) { is SelectAddonToRemoveState.Error -> { @@ -143,23 +115,11 @@ private fun SelectAddonToRemoveScreen( } } - is SelectAddonToRemoveState.Loading -> { - LaunchedEffect(uiState.paramsToNavigateToSummary) { - val summaryParams = uiState.paramsToNavigateToSummary - if (summaryParams != null) { - navigateToSummary(summaryParams, true) - } - } + SelectAddonToRemoveState.Loading -> { HedvigFullScreenCenterAlignedProgress() } is SelectAddonToRemoveState.Success -> { - LaunchedEffect(uiState.paramsToNavigateToSummary) { - val summaryParams = uiState.paramsToNavigateToSummary - if (summaryParams != null) { - navigateToSummary(summaryParams, false) - } - } SelectAddonToRemoveSuccessScreen( uiState = uiState, navigateUp = navigateUp, @@ -296,7 +256,6 @@ private fun PreviewChooseInsuranceToRemoveAddonScreen( {}, {}, {}, - { _, _ -> }, ) } } @@ -305,7 +264,7 @@ private fun PreviewChooseInsuranceToRemoveAddonScreen( private class SelectAddonToRemoveStateProvider : CollectionPreviewParameterProvider( listOf( - SelectAddonToRemoveState.Loading(), + SelectAddonToRemoveState.Loading, SelectAddonToRemoveState.Error(null), SelectAddonToRemoveState.Error("Big error message"), SelectAddonToRemoveState.Success( @@ -370,7 +329,6 @@ private class SelectAddonToRemoveStateProvider : id = AddonId("id1"), ), ), - paramsToNavigateToSummary = null, ), ), ) diff --git a/app/feature/feature-remove-addons/src/commonMain/kotlin/com/hedvig/feature/remove/addons/ui/SelectAddonToRemoveViewModel.kt b/app/feature/feature-remove-addons/src/commonMain/kotlin/com/hedvig/feature/remove/addons/ui/SelectAddonToRemoveViewModel.kt index b18b7b0815..68fd7402c8 100644 --- a/app/feature/feature-remove-addons/src/commonMain/kotlin/com/hedvig/feature/remove/addons/ui/SelectAddonToRemoveViewModel.kt +++ b/app/feature/feature-remove-addons/src/commonMain/kotlin/com/hedvig/feature/remove/addons/ui/SelectAddonToRemoveViewModel.kt @@ -10,13 +10,17 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.Snapshot import com.hedvig.android.core.common.di.AppScope -import com.hedvig.android.core.uidata.ItemCost import com.hedvig.android.data.contract.ContractId import com.hedvig.android.data.productvariant.AddonVariant -import com.hedvig.android.data.productvariant.ProductVariant import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel +import com.hedvig.android.navigation.compose.Backstack +import com.hedvig.android.navigation.compose.add +import com.hedvig.android.navigation.compose.navigateAndPopUpTo +import com.hedvig.feature.remove.addons.ChooseAddonToRemoveKey +import com.hedvig.feature.remove.addons.RemoveAddonSummaryKey +import com.hedvig.feature.remove.addons.SummaryParameters import com.hedvig.feature.remove.addons.data.CurrentlyActiveAddon import com.hedvig.feature.remove.addons.data.StartAddonRemovalResponse import com.hedvig.feature.remove.addons.data.StartAddonRemovalUseCase @@ -26,16 +30,16 @@ import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactory import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactoryKey -import kotlinx.datetime.LocalDate @AssistedInject internal class SelectAddonToRemoveViewModel( startAddonRemovalUseCase: StartAddonRemovalUseCase, + backstack: Backstack, @Assisted contractId: ContractId, @Assisted preselectedAddonVariant: AddonVariant?, ) : MoleculeViewModel( - initialState = SelectAddonToRemoveState.Loading(), - presenter = SelectAddonToRemovePresenter(startAddonRemovalUseCase, contractId, preselectedAddonVariant), + initialState = SelectAddonToRemoveState.Loading, + presenter = SelectAddonToRemovePresenter(startAddonRemovalUseCase, backstack, contractId, preselectedAddonVariant), ) { @AssistedFactory @ManualViewModelAssistedFactoryKey @@ -50,6 +54,7 @@ internal class SelectAddonToRemoveViewModel( private class SelectAddonToRemovePresenter( private val startAddonRemovalUseCase: StartAddonRemovalUseCase, + private val backstack: Backstack, private val contractId: ContractId, private val preselectedAddonVariant: AddonVariant?, ) : MoleculePresenter { @@ -67,7 +72,6 @@ private class SelectAddonToRemovePresenter( val addonsChosenForRemoval = (lastState as? SelectAddonToRemoveState.Success)?.addonsChosenForRemoval.orEmpty() mutableStateListOf(*addonsChosenForRemoval.toTypedArray()) } - var paramsToNavigateToSummary by remember { mutableStateOf(null) } LaunchedEffect(loadIteration) { if (loadIteration != 0) { @@ -86,22 +90,24 @@ private class SelectAddonToRemovePresenter( ) } ?: if (result.existingAddonsToRemove.size == 1) result.existingAddonsToRemove else emptyList() - val summaryParams = if (result.existingAddonsToRemove.size == 1) { - CommonSummaryParameters( - contractId = contractId, - addonsToRemove = result.existingAddonsToRemove, - activationDate = result.activationDate, - baseCost = result.baseCost, - currentTotalCost = result.currentTotalCost, - productVariant = result.productVariant, - existingAddons = result.existingAddonsToRemove, - ) - } else { - null - } - if (summaryParams != null) { + if (result.existingAddonsToRemove.size == 1) { + // Single removable addon: skip the picker and jump straight to the summary, popping the + // picker so back leaves the flow. isLoading = true - paramsToNavigateToSummary = summaryParams + backstack.navigateAndPopUpTo( + RemoveAddonSummaryKey( + SummaryParameters( + contractId = contractId, + addonsToRemove = result.existingAddonsToRemove, + activationDate = result.activationDate, + baseCost = result.baseCost, + currentTotalCost = result.currentTotalCost, + productVariant = result.productVariant, + existingAddons = result.existingAddonsToRemove, + ), + ), + inclusive = true, + ) } else { Snapshot.withMutableSnapshot { response = result @@ -121,22 +127,21 @@ private class SelectAddonToRemovePresenter( loadIteration++ } - SelectAddonToRemoveEvent.ClearNavigation -> { - paramsToNavigateToSummary = null - } - SelectAddonToRemoveEvent.Submit -> { val responseValue = response ?: return@CollectEvents - val summaryParams = CommonSummaryParameters( - contractId = contractId, - addonsToRemove = selectedToggleableOptions, - activationDate = responseValue.activationDate, - baseCost = responseValue.baseCost, - currentTotalCost = responseValue.currentTotalCost, - productVariant = responseValue.productVariant, - existingAddons = responseValue.existingAddonsToRemove, + backstack.add( + RemoveAddonSummaryKey( + SummaryParameters( + contractId = contractId, + addonsToRemove = selectedToggleableOptions, + activationDate = responseValue.activationDate, + baseCost = responseValue.baseCost, + currentTotalCost = responseValue.currentTotalCost, + productVariant = responseValue.productVariant, + existingAddons = responseValue.existingAddonsToRemove, + ), + ), ) - paramsToNavigateToSummary = summaryParams } is SelectAddonToRemoveEvent.ToggleOption -> { @@ -153,12 +158,11 @@ private class SelectAddonToRemovePresenter( return when { errorMessage != null -> SelectAddonToRemoveState.Error(errorMessage) - isLoading -> SelectAddonToRemoveState.Loading(paramsToNavigateToSummary = paramsToNavigateToSummary) + isLoading -> SelectAddonToRemoveState.Loading responseValue != null -> SelectAddonToRemoveState.Success( addonOffer = responseValue, addonsChosenForRemoval = selectedToggleableOptions, - paramsToNavigateToSummary = paramsToNavigateToSummary, ) else -> SelectAddonToRemoveState.Error(null) @@ -170,32 +174,17 @@ internal sealed interface SelectAddonToRemoveState { data class Success( val addonOffer: StartAddonRemovalResponse, val addonsChosenForRemoval: List, - val paramsToNavigateToSummary: CommonSummaryParameters? = null, ) : SelectAddonToRemoveState data class Error(val message: String?) : SelectAddonToRemoveState - data class Loading( - val paramsToNavigateToSummary: CommonSummaryParameters? = null, - ) : SelectAddonToRemoveState + data object Loading : SelectAddonToRemoveState } internal sealed interface SelectAddonToRemoveEvent { data object Retry : SelectAddonToRemoveEvent - data object ClearNavigation : SelectAddonToRemoveEvent - data object Submit : SelectAddonToRemoveEvent data class ToggleOption(val option: CurrentlyActiveAddon) : SelectAddonToRemoveEvent } - -internal data class CommonSummaryParameters( - val contractId: ContractId, - val addonsToRemove: List, - val activationDate: LocalDate, - val baseCost: ItemCost, - val currentTotalCost: ItemCost, - val productVariant: ProductVariant, - val existingAddons: List, -) diff --git a/app/feature/feature-remove-addons/src/commonMain/kotlin/com/hedvig/feature/remove/addons/ui/SelectInsuranceToRemoveAddonDestination.kt b/app/feature/feature-remove-addons/src/commonMain/kotlin/com/hedvig/feature/remove/addons/ui/SelectInsuranceToRemoveAddonDestination.kt index ce6224a209..fd8458b2db 100644 --- a/app/feature/feature-remove-addons/src/commonMain/kotlin/com/hedvig/feature/remove/addons/ui/SelectInsuranceToRemoveAddonDestination.kt +++ b/app/feature/feature-remove-addons/src/commonMain/kotlin/com/hedvig/feature/remove/addons/ui/SelectInsuranceToRemoveAddonDestination.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.PreviewParameter @@ -41,19 +40,12 @@ import hedvig.resources.general_continue_button import org.jetbrains.compose.resources.stringResource @Composable -internal fun SelectInsuranceToRemoveAddonDestination( - navigateUp: () -> Unit, - navigateToChooseAddon: (ContractId) -> Unit, -) { +internal fun SelectInsuranceToRemoveAddonDestination(navigateUp: () -> Unit) { val viewModel: SelectInsuranceToRemoveAddonViewModel = metroViewModel() val uiState by viewModel.uiState.collectAsStateWithLifecycle() SelectInsuranceToRemoveAddonScreen( uiState = uiState, navigateUp = navigateUp, - navigateToChooseAddon = { id -> - navigateToChooseAddon(id) - viewModel.emit(SelectInsuranceToRemoveAddonEvent.ClearNavigation) - }, selectInsurance = { selected -> viewModel.emit(SelectInsuranceToRemoveAddonEvent.SelectInsurance(selected)) }, @@ -70,7 +62,6 @@ internal fun SelectInsuranceToRemoveAddonDestination( private fun SelectInsuranceToRemoveAddonScreen( uiState: SelectInsuranceToRemoveAddonState, navigateUp: () -> Unit, - navigateToChooseAddon: (ContractId) -> Unit, selectInsurance: (ContractId) -> Unit, submitSelected: (ContractId) -> Unit, reload: () -> Unit, @@ -89,11 +80,6 @@ private fun SelectInsuranceToRemoveAddonScreen( } is SelectInsuranceToRemoveAddonState.Success -> { - LaunchedEffect(uiState.insuranceIdToContinue) { - if (uiState.insuranceIdToContinue != null) { - navigateToChooseAddon(uiState.insuranceIdToContinue) - } - } SelectInsuranceToRemoveAddonContentScreen( uiState = uiState, navigateUp = navigateUp, @@ -197,7 +183,6 @@ private fun PreviewChooseInsuranceToRemoveAddonScreen( {}, {}, {}, - {}, ) } } @@ -222,7 +207,6 @@ private class ChooseInsuranceToRemoveAddonUiStateProvider : ), ), currentlySelected = null, - insuranceIdToContinue = null, ), SelectInsuranceToRemoveAddonState.Success( listOfInsurances = listOf( @@ -245,7 +229,6 @@ private class ChooseInsuranceToRemoveAddonUiStateProvider : contractExposure = "Opulullegatan 19", contractGroup = ContractGroup.HOUSE, ), - insuranceIdToContinue = null, ), SelectInsuranceToRemoveAddonState.Error, SelectInsuranceToRemoveAddonState.Loading, diff --git a/app/feature/feature-remove-addons/src/commonMain/kotlin/com/hedvig/feature/remove/addons/ui/SelectInsuranceToRemoveAddonViewModel.kt b/app/feature/feature-remove-addons/src/commonMain/kotlin/com/hedvig/feature/remove/addons/ui/SelectInsuranceToRemoveAddonViewModel.kt index dbd425a43e..b1ceb9a929 100644 --- a/app/feature/feature-remove-addons/src/commonMain/kotlin/com/hedvig/feature/remove/addons/ui/SelectInsuranceToRemoveAddonViewModel.kt +++ b/app/feature/feature-remove-addons/src/commonMain/kotlin/com/hedvig/feature/remove/addons/ui/SelectInsuranceToRemoveAddonViewModel.kt @@ -13,6 +13,9 @@ import com.hedvig.android.data.contract.ContractId import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel +import com.hedvig.android.navigation.compose.Backstack +import com.hedvig.android.navigation.compose.add +import com.hedvig.feature.remove.addons.ChooseAddonToRemoveKey import com.hedvig.feature.remove.addons.data.GetInsurancesWithRemovableAddonsUseCase import com.hedvig.feature.remove.addons.data.InsuranceForAddon import dev.zacsweers.metro.ContributesIntoMap @@ -25,16 +28,18 @@ import dev.zacsweers.metrox.viewmodel.ViewModelKey @ContributesIntoMap(AppScope::class, binding()) internal class SelectInsuranceToRemoveAddonViewModel( getInsurancesWithRemovableAddonsUseCase: GetInsurancesWithRemovableAddonsUseCase, + backstack: Backstack, ) : MoleculeViewModel< SelectInsuranceToRemoveAddonEvent, SelectInsuranceToRemoveAddonState, >( initialState = SelectInsuranceToRemoveAddonState.Loading, - presenter = SelectInsuranceToRemoveAddonPresenter(getInsurancesWithRemovableAddonsUseCase), + presenter = SelectInsuranceToRemoveAddonPresenter(getInsurancesWithRemovableAddonsUseCase, backstack), ) private class SelectInsuranceToRemoveAddonPresenter( val getInsurancesWithRemovableAddonsUseCase: GetInsurancesWithRemovableAddonsUseCase, + private val backstack: Backstack, ) : MoleculePresenter< SelectInsuranceToRemoveAddonEvent, SelectInsuranceToRemoveAddonState, @@ -45,7 +50,6 @@ private class SelectInsuranceToRemoveAddonPresenter( ): SelectInsuranceToRemoveAddonState { var currentState: SelectInsuranceToRemoveAddonState by remember { mutableStateOf(lastState) } var loadIteration by remember { mutableIntStateOf(0) } - var insuranceIdToContinue by remember { mutableStateOf(null) } var currentlySelected by remember { mutableStateOf(null) } LaunchedEffect(loadIteration) { @@ -54,13 +58,15 @@ private class SelectInsuranceToRemoveAddonPresenter( currentState = SelectInsuranceToRemoveAddonState.Error }, ifRight = { - currentState = if (it.isEmpty()) { - SelectInsuranceToRemoveAddonState.EmptyList + if (it.isEmpty()) { + currentState = SelectInsuranceToRemoveAddonState.EmptyList + } else if (it.size == 1) { + // Single eligible insurance: skip the picker and jump straight to the addon picker. + backstack.add(ChooseAddonToRemoveKey(it.first().contractId, preselectedAddonVariant = null)) } else { - SelectInsuranceToRemoveAddonState.Success( + currentState = SelectInsuranceToRemoveAddonState.Success( listOfInsurances = it, currentlySelected = null, - insuranceIdToContinue = if (it.size == 1) it.first().contractId else null, ) } }, @@ -73,10 +79,6 @@ private class SelectInsuranceToRemoveAddonPresenter( loadIteration++ } - SelectInsuranceToRemoveAddonEvent.ClearNavigation -> { - insuranceIdToContinue = null - } - is SelectInsuranceToRemoveAddonEvent.SelectInsurance -> { val state = currentState as? SelectInsuranceToRemoveAddonState.Success ?: return@CollectEvents val selected = state.listOfInsurances.firstOrNull { it.contractId == event.contractId } @@ -85,7 +87,8 @@ private class SelectInsuranceToRemoveAddonPresenter( } is SelectInsuranceToRemoveAddonEvent.SubmitSelected -> { - insuranceIdToContinue = currentlySelected?.contractId + val selected = currentlySelected ?: return@CollectEvents + backstack.add(ChooseAddonToRemoveKey(selected.contractId, preselectedAddonVariant = null)) } } } @@ -96,7 +99,6 @@ private class SelectInsuranceToRemoveAddonPresenter( -> state is SelectInsuranceToRemoveAddonState.Success -> state.copy( - insuranceIdToContinue = insuranceIdToContinue, currentlySelected = currentlySelected, ) } @@ -107,7 +109,6 @@ internal sealed interface SelectInsuranceToRemoveAddonState { data class Success( val listOfInsurances: List, val currentlySelected: InsuranceForAddon?, - val insuranceIdToContinue: ContractId? = null, ) : SelectInsuranceToRemoveAddonState data object Error : SelectInsuranceToRemoveAddonState @@ -120,8 +121,6 @@ internal sealed interface SelectInsuranceToRemoveAddonState { internal sealed interface SelectInsuranceToRemoveAddonEvent { data object Reload : SelectInsuranceToRemoveAddonEvent - data object ClearNavigation : SelectInsuranceToRemoveAddonEvent - data class SelectInsurance(val contractId: ContractId) : SelectInsuranceToRemoveAddonEvent data class SubmitSelected(val contractId: ContractId) : SelectInsuranceToRemoveAddonEvent diff --git a/app/feature/feature-terminate-insurance/src/main/kotlin/com/hedvig/android/feature/terminateinsurance/navigation/TerminateInsuranceEntries.kt b/app/feature/feature-terminate-insurance/src/main/kotlin/com/hedvig/android/feature/terminateinsurance/navigation/TerminateInsuranceEntries.kt index e19eea6562..bc12af2803 100644 --- a/app/feature/feature-terminate-insurance/src/main/kotlin/com/hedvig/android/feature/terminateinsurance/navigation/TerminateInsuranceEntries.kt +++ b/app/feature/feature-terminate-insurance/src/main/kotlin/com/hedvig/android/feature/terminateinsurance/navigation/TerminateInsuranceEntries.kt @@ -5,13 +5,11 @@ import androidx.lifecycle.compose.dropUnlessResumed import androidx.navigation3.runtime.EntryProviderScope import com.hedvig.android.core.common.ErrorMessage import com.hedvig.android.data.changetier.data.IntentOutput -import com.hedvig.android.feature.terminateinsurance.data.SuggestionType import com.hedvig.android.feature.terminateinsurance.data.TerminationAction import com.hedvig.android.feature.terminateinsurance.step.choose.ChooseInsuranceToTerminateDestination import com.hedvig.android.feature.terminateinsurance.step.choose.ChooseInsuranceToTerminateViewModel import com.hedvig.android.feature.terminateinsurance.step.deflect.DeflectSuggestionDestination import com.hedvig.android.feature.terminateinsurance.step.deletion.InsuranceDeletionDestination -import com.hedvig.android.feature.terminateinsurance.step.survey.SurveyNavigationStep import com.hedvig.android.feature.terminateinsurance.step.survey.TerminationSurveyDestination import com.hedvig.android.feature.terminateinsurance.step.survey.TerminationSurveyViewModel import com.hedvig.android.feature.terminateinsurance.step.terminationdate.TerminationDateDestination @@ -25,7 +23,6 @@ import com.hedvig.android.feature.terminateinsurance.step.unknown.UnknownScreenD import com.hedvig.android.navigation.common.HedvigNavKey import com.hedvig.android.navigation.compose.Backstack import com.hedvig.android.navigation.compose.add -import com.hedvig.android.navigation.compose.navigateAndPopUpTo import com.hedvig.android.navigation.compose.popBackstack import dev.zacsweers.metrox.viewmodel.assistedMetroViewModel @@ -80,44 +77,20 @@ fun EntryProviderScope.terminateInsuranceEntries( navigateUp = backstack::navigateUp, onNavigateToNewConversation = dropUnlessResumed { onNavigateToNewConversation() }, closeTerminationFlow = closeTerminationFlow, - navigateToNextStep = { surveyData, insuranceForCancellation -> - val commonParams = TerminationGraphParameters( - insuranceForCancellation.id, - insuranceForCancellation.displayName, - insuranceForCancellation.contractExposure, - insuranceForCancellation.contractGroup, - ) - backstack.navigateToTerminateFlowDestination( - TerminationSurveyFirstStepKey( - options = surveyData.options, - action = surveyData.action, - commonParams = commonParams, - ), - ) - }, ) } entry { key -> val surveyOptions = key.options val surveyAction = key.action - val surveyContractId = key.commonParams.contractId val viewModel: TerminationSurveyViewModel = assistedMetroViewModel { - create(surveyOptions, surveyAction, surveyContractId) + create(surveyOptions, surveyAction, key.commonParams) } TerminationSurveyDestination( viewModel, navigateUp = backstack::navigateUp, closeTerminationFlow = closeTerminationFlow, - navigateToSubOptions = { subOptions -> - backstack.add( - TerminationSurveySecondStepKey(subOptions, key.action, key.commonParams), - ) - }, - navigateToNextStep = { navStep -> - navigateFromSurvey(backstack, navStep, key.commonParams) - }, navigateToMovingFlow = navigateToMovingFlow, openUrl = openUrl, redirectToChangeTierFlow = { intent -> @@ -129,27 +102,14 @@ fun EntryProviderScope.terminateInsuranceEntries( entry { key -> val surveySubOptions = key.subOptions val surveyAction = key.action - val surveyContractId = key.commonParams.contractId val viewModel: TerminationSurveyViewModel = assistedMetroViewModel { - create(surveySubOptions, surveyAction, surveyContractId) + create(surveySubOptions, surveyAction, key.commonParams) } TerminationSurveyDestination( viewModel, navigateUp = backstack::navigateUp, closeTerminationFlow = closeTerminationFlow, - navigateToSubOptions = { nestedSubOptions -> - backstack.add( - TerminationSurveySecondStepKey( - nestedSubOptions, - key.action, - key.commonParams, - ), - ) - }, - navigateToNextStep = { navStep -> - navigateFromSurvey(backstack, navStep, key.commonParams) - }, navigateToMovingFlow = navigateToMovingFlow, openUrl = openUrl, redirectToChangeTierFlow = { intent -> @@ -229,12 +189,6 @@ fun EntryProviderScope.terminateInsuranceEntries( onContinue = { viewModel.emit(TerminationConfirmationEvent.Submit) }, - navigateToSuccess = { terminationDate -> - viewModel.emit(TerminationConfirmationEvent.HandledNavigation) - backstack.navigateToTerminateFlowDestination( - TerminationSuccessKey(terminationDate), - ) - }, navigateUp = backstack::navigateUp, closeTerminationFlow = closeTerminationFlow, ) @@ -277,68 +231,3 @@ fun EntryProviderScope.terminateInsuranceEntries( ) } } - -private fun navigateFromSurvey( - backstack: Backstack, - navStep: SurveyNavigationStep.NavigateToNextTerminationStep, - commonParams: TerminationGraphParameters, -) { - val selectedOption = navStep.selectedOption - val suggestion = selectedOption.suggestion - - // Handle deflection suggestions as full-screen destinations - if (suggestion != null && - suggestion.type in SuggestionType.DEFLECT_TYPES - ) { - backstack.add( - DeflectSuggestionKey( - description = suggestion.description, - url = suggestion.url, - suggestionType = suggestion.type, - commonParams = commonParams, - action = navStep.action, - selectedReasonId = selectedOption.id, - feedbackComment = navStep.feedbackText, - ), - ) - return - } - - // Navigate based on the termination action - when (val terminationAction = navStep.action) { - is TerminationAction.TerminateWithDate -> { - backstack.add( - TerminationDateKey( - minDate = terminationAction.minDate, - maxDate = terminationAction.maxDate, - extraCoverageItems = terminationAction.extraCoverageItems, - commonParams = commonParams, - selectedReasonId = selectedOption.id, - feedbackComment = navStep.feedbackText, - ), - ) - } - - is TerminationAction.DeleteInsurance -> { - backstack.add( - InsuranceDeletionKey( - commonParams = commonParams, - extraCoverageItems = terminationAction.extraCoverageItems, - selectedReasonId = selectedOption.id, - feedbackComment = navStep.feedbackText, - ), - ) - } - } -} - -private fun Backstack.navigateToTerminateFlowDestination(destination: HedvigNavKey) { - when (destination) { - is TerminationSuccessKey, - is TerminationFailureKey, - is UnknownScreenKey, - -> navigateAndPopUpTo(destination, inclusive = true) - - else -> add(destination) - } -} diff --git a/app/feature/feature-terminate-insurance/src/main/kotlin/com/hedvig/android/feature/terminateinsurance/step/choose/ChooseInsuranceToTerminateDestination.kt b/app/feature/feature-terminate-insurance/src/main/kotlin/com/hedvig/android/feature/terminateinsurance/step/choose/ChooseInsuranceToTerminateDestination.kt index a52017d7b5..86678d633a 100644 --- a/app/feature/feature-terminate-insurance/src/main/kotlin/com/hedvig/android/feature/terminateinsurance/step/choose/ChooseInsuranceToTerminateDestination.kt +++ b/app/feature/feature-terminate-insurance/src/main/kotlin/com/hedvig/android/feature/terminateinsurance/step/choose/ChooseInsuranceToTerminateDestination.kt @@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.PreviewParameter @@ -32,7 +31,6 @@ import com.hedvig.android.design.system.hedvig.RadioOption import com.hedvig.android.design.system.hedvig.RadioOptionId import com.hedvig.android.design.system.hedvig.Surface import com.hedvig.android.design.system.hedvig.a11y.FlowHeading -import com.hedvig.android.feature.terminateinsurance.data.TerminationSurveyData import com.hedvig.android.feature.terminateinsurance.ui.TerminationScaffold import hedvig.resources.Res import hedvig.resources.TERMINATION_FLOW_CANCEL_INFO_TEXT @@ -50,16 +48,8 @@ internal fun ChooseInsuranceToTerminateDestination( navigateUp: () -> Unit, onNavigateToNewConversation: () -> Unit, closeTerminationFlow: () -> Unit, - navigateToNextStep: (surveyData: TerminationSurveyData, terminatableInsurance: TerminatableInsurance) -> Unit, ) { val uiState: ChooseInsuranceToTerminateStepUiState by viewModel.uiState.collectAsStateWithLifecycle() - LaunchedEffect(uiState) { - val uiStateValue = uiState as? ChooseInsuranceToTerminateStepUiState.Success ?: return@LaunchedEffect - if (uiStateValue.nextStepWithInsurance != null) { - viewModel.emit(ChooseInsuranceToTerminateEvent.ClearTerminationStep) - navigateToNextStep(uiStateValue.nextStepWithInsurance.first, uiStateValue.nextStepWithInsurance.second) - } - } ChooseInsuranceToTerminateScreen( uiState = uiState, navigateUp = navigateUp, @@ -199,7 +189,6 @@ private class ChooseInsuranceToTerminateStepUiStateProvider : CollectionPreviewParameterProvider( listOf( ChooseInsuranceToTerminateStepUiState.Success( - nextStepWithInsurance = null, insuranceList = listOf( TerminatableInsurance( id = "1", @@ -219,7 +208,6 @@ private class ChooseInsuranceToTerminateStepUiStateProvider : navigationStepFailedToLoad = false, ), ChooseInsuranceToTerminateStepUiState.Success( - nextStepWithInsurance = null, insuranceList = listOf( TerminatableInsurance( id = "1", diff --git a/app/feature/feature-terminate-insurance/src/main/kotlin/com/hedvig/android/feature/terminateinsurance/step/choose/ChooseInsuranceToTerminateViewModel.kt b/app/feature/feature-terminate-insurance/src/main/kotlin/com/hedvig/android/feature/terminateinsurance/step/choose/ChooseInsuranceToTerminateViewModel.kt index b19ae7156b..dc0fed8a9e 100644 --- a/app/feature/feature-terminate-insurance/src/main/kotlin/com/hedvig/android/feature/terminateinsurance/step/choose/ChooseInsuranceToTerminateViewModel.kt +++ b/app/feature/feature-terminate-insurance/src/main/kotlin/com/hedvig/android/feature/terminateinsurance/step/choose/ChooseInsuranceToTerminateViewModel.kt @@ -11,12 +11,15 @@ import com.hedvig.android.core.common.di.AppScope import com.hedvig.android.data.termination.data.GetTerminatableContractsUseCase import com.hedvig.android.data.termination.data.TerminatableInsurance import com.hedvig.android.feature.terminateinsurance.data.TerminateInsuranceRepository -import com.hedvig.android.feature.terminateinsurance.data.TerminationSurveyData +import com.hedvig.android.feature.terminateinsurance.navigation.TerminationGraphParameters +import com.hedvig.android.feature.terminateinsurance.navigation.TerminationSurveyFirstStepKey import com.hedvig.android.logger.LogPriority import com.hedvig.android.logger.logcat import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel +import com.hedvig.android.navigation.compose.Backstack +import com.hedvig.android.navigation.compose.add import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject @@ -28,12 +31,14 @@ internal class ChooseInsuranceToTerminateViewModel @AssistedInject constructor( @Assisted insuranceId: String?, getTerminatableContractsUseCase: GetTerminatableContractsUseCase, terminateInsuranceRepository: TerminateInsuranceRepository, + backstack: Backstack, ) : MoleculeViewModel( initialState = ChooseInsuranceToTerminateStepUiState.Loading, presenter = ChooseInsuranceToTerminatePresenter( insuranceId = insuranceId, getTerminatableContractsUseCase = getTerminatableContractsUseCase, terminateInsuranceRepository = terminateInsuranceRepository, + backstack = backstack, ), ) { @AssistedFactory @@ -50,6 +55,7 @@ private class ChooseInsuranceToTerminatePresenter( private val insuranceId: String?, private val getTerminatableContractsUseCase: GetTerminatableContractsUseCase, private val terminateInsuranceRepository: TerminateInsuranceRepository, + private val backstack: Backstack, ) : MoleculePresenter { @Composable override fun MoleculePresenterScope.present( @@ -86,16 +92,6 @@ private class ChooseInsuranceToTerminatePresenter( ) } } - - ChooseInsuranceToTerminateEvent.ClearTerminationStep -> { - val currentStateValue = currentState - if (currentStateValue is ChooseInsuranceToTerminateStepUiState.Success) { - currentState = currentStateValue.copy( - nextStepWithInsurance = null, - isNavigationStepLoading = false, - ) - } - } } } @@ -121,10 +117,19 @@ private class ChooseInsuranceToTerminatePresenter( ) }, ifRight = { surveyData -> - loadingState.copy( - nextStepWithInsurance = Pair(surveyData, terminatableInsurance), - isNavigationStepLoading = false, + backstack.add( + TerminationSurveyFirstStepKey( + options = surveyData.options, + action = surveyData.action, + commonParams = TerminationGraphParameters( + terminatableInsurance.id, + terminatableInsurance.displayName, + terminatableInsurance.contractExposure, + terminatableInsurance.contractGroup, + ), + ), ) + loadingState.copy(isNavigationStepLoading = false) }, ) terminatableInsuranceToFetchNextStepFor = null @@ -153,7 +158,6 @@ private class ChooseInsuranceToTerminatePresenter( ChooseInsuranceToTerminateStepUiState.Success( insuranceList = eligibleInsurances, selectedInsurance = selectedInsurance, - nextStepWithInsurance = null, isNavigationStepLoading = false, navigationStepFailedToLoad = false, ) @@ -174,8 +178,6 @@ internal sealed interface ChooseInsuranceToTerminateEvent { data class SubmitSelectedInsuranceToTerminate(val insurance: TerminatableInsurance) : ChooseInsuranceToTerminateEvent - - data object ClearTerminationStep : ChooseInsuranceToTerminateEvent } internal sealed interface ChooseInsuranceToTerminateStepUiState { @@ -184,7 +186,6 @@ internal sealed interface ChooseInsuranceToTerminateStepUiState { data class Success( val insuranceList: List, val selectedInsurance: TerminatableInsurance?, - val nextStepWithInsurance: Pair?, val isNavigationStepLoading: Boolean, val navigationStepFailedToLoad: Boolean, ) : ChooseInsuranceToTerminateStepUiState diff --git a/app/feature/feature-terminate-insurance/src/main/kotlin/com/hedvig/android/feature/terminateinsurance/step/survey/TerminationSurveyDestination.kt b/app/feature/feature-terminate-insurance/src/main/kotlin/com/hedvig/android/feature/terminateinsurance/step/survey/TerminationSurveyDestination.kt index 342c955cdd..91c65e743d 100644 --- a/app/feature/feature-terminate-insurance/src/main/kotlin/com/hedvig/android/feature/terminateinsurance/step/survey/TerminationSurveyDestination.kt +++ b/app/feature/feature-terminate-insurance/src/main/kotlin/com/hedvig/android/feature/terminateinsurance/step/survey/TerminationSurveyDestination.kt @@ -74,8 +74,6 @@ internal fun TerminationSurveyDestination( navigateToMovingFlow: () -> Unit, closeTerminationFlow: () -> Unit, openUrl: (String) -> Unit, - navigateToNextStep: (SurveyNavigationStep.NavigateToNextTerminationStep) -> Unit, - navigateToSubOptions: ((List) -> Unit)?, redirectToChangeTierFlow: (Pair) -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -86,24 +84,6 @@ internal fun TerminationSurveyDestination( redirectToChangeTierFlow(intent) } } - LaunchedEffect(uiState.nextNavigationStep) { - val nextStep = uiState.nextNavigationStep - if (nextStep != null) { - when (nextStep) { - is SurveyNavigationStep.NavigateToNextTerminationStep -> { - viewModel.emit(TerminationSurveyEvent.ClearNextStep) - navigateToNextStep(nextStep) - } - - SurveyNavigationStep.NavigateToSubOptions -> { - viewModel.emit(TerminationSurveyEvent.ClearNextStep) - uiState.selectedOption?.let { - navigateToSubOptions?.invoke(it.subOptions) - } - } - } - } - } TerminationSurveyScreen( uiState = uiState, navigateUp = navigateUp, @@ -257,7 +237,7 @@ private fun TerminationSurveyScreen( .fillMaxWidth() .padding(horizontal = 16.dp), onClick = onContinueClick, - isLoading = uiState.navigationStepLoading, + isLoading = false, ) } Spacer(Modifier.height(16.dp)) diff --git a/app/feature/feature-terminate-insurance/src/main/kotlin/com/hedvig/android/feature/terminateinsurance/step/survey/TerminationSurveyViewModel.kt b/app/feature/feature-terminate-insurance/src/main/kotlin/com/hedvig/android/feature/terminateinsurance/step/survey/TerminationSurveyViewModel.kt index 9779e358c1..ccf4759057 100644 --- a/app/feature/feature-terminate-insurance/src/main/kotlin/com/hedvig/android/feature/terminateinsurance/step/survey/TerminationSurveyViewModel.kt +++ b/app/feature/feature-terminate-insurance/src/main/kotlin/com/hedvig/android/feature/terminateinsurance/step/survey/TerminationSurveyViewModel.kt @@ -16,7 +16,11 @@ import com.hedvig.android.data.changetier.data.IntentOutput import com.hedvig.android.feature.terminateinsurance.data.SuggestionType import com.hedvig.android.feature.terminateinsurance.data.TerminationAction import com.hedvig.android.feature.terminateinsurance.data.TerminationSurveyOption -import com.hedvig.android.feature.terminateinsurance.step.survey.SurveyNavigationStep.NavigateToSubOptions +import com.hedvig.android.feature.terminateinsurance.navigation.DeflectSuggestionKey +import com.hedvig.android.feature.terminateinsurance.navigation.InsuranceDeletionKey +import com.hedvig.android.feature.terminateinsurance.navigation.TerminationDateKey +import com.hedvig.android.feature.terminateinsurance.navigation.TerminationGraphParameters +import com.hedvig.android.feature.terminateinsurance.navigation.TerminationSurveySecondStepKey import com.hedvig.android.feature.terminateinsurance.step.survey.TerminationSurveyEvent.ClearEmptyQuotesDialog import com.hedvig.android.feature.terminateinsurance.step.survey.TerminationSurveyEvent.ClearNextStep import com.hedvig.android.feature.terminateinsurance.step.survey.TerminationSurveyEvent.CloseFullScreenEditText @@ -31,6 +35,8 @@ import com.hedvig.android.logger.logcat import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel +import com.hedvig.android.navigation.compose.Backstack +import com.hedvig.android.navigation.compose.add import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject @@ -41,15 +47,17 @@ import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactoryKey internal class TerminationSurveyViewModel @AssistedInject constructor( @Assisted options: List, @Assisted action: TerminationAction, + @Assisted commonParams: TerminationGraphParameters, changeTierRepository: ChangeTierRepository, - @Assisted contractId: String, + backstack: Backstack, ) : MoleculeViewModel( initialState = TerminationSurveyState(options), presenter = TerminationSurveyPresenter( options, action, + commonParams, changeTierRepository, - contractId, + backstack, ), ) { @AssistedFactory @@ -59,7 +67,7 @@ internal class TerminationSurveyViewModel @AssistedInject constructor( fun create( @Assisted options: List, @Assisted action: TerminationAction, - @Assisted contractId: String, + @Assisted commonParams: TerminationGraphParameters, ): TerminationSurveyViewModel } } @@ -67,15 +75,17 @@ internal class TerminationSurveyViewModel @AssistedInject constructor( internal class TerminationSurveyPresenter( private val options: List, private val action: TerminationAction, + private val commonParams: TerminationGraphParameters, private val changeTierRepository: ChangeTierRepository, - private val contractId: String, + private val backstack: Backstack, ) : MoleculePresenter { + private val contractId: String = commonParams.contractId + @Composable override fun MoleculePresenterScope.present( lastState: TerminationSurveyState, ): TerminationSurveyState { var loadBetterQuotesSource by remember { mutableStateOf(null) } - var loadNextStep by remember { mutableStateOf(false) } var feedbackText: String? by remember { mutableStateOf(lastState.feedbackText) } var showFullScreenTextField by remember { @@ -102,14 +112,16 @@ internal class TerminationSurveyPresenter( val selectedOption = currentState.selectedOption ?: return@CollectEvents currentState = currentState.copy(errorWhileLoadingNextStep = false) if (selectedOption.subOptions.isNotEmpty()) { - currentState = currentState.copy(nextNavigationStep = NavigateToSubOptions) + backstack.add( + TerminationSurveySecondStepKey(selectedOption.subOptions, action, commonParams), + ) } else { - loadNextStep = true + backstack.navigateAfterSurvey(selectedOption, feedbackText, action, commonParams) } } ClearNextStep -> { - currentState = currentState.copy(nextNavigationStep = null, intentAndIdToRedirectToChangeTierFlow = null) + currentState = currentState.copy(intentAndIdToRedirectToChangeTierFlow = null) } is ShowFullScreenEditText -> { @@ -202,20 +214,6 @@ internal class TerminationSurveyPresenter( ) } - if (loadNextStep) { - LaunchedEffect(Unit) { - val reasonToSubmit = currentState.selectedOption ?: return@LaunchedEffect - loadNextStep = false - currentState = currentState.copy( - nextNavigationStep = SurveyNavigationStep.NavigateToNextTerminationStep( - selectedOption = reasonToSubmit, - feedbackText = feedbackText, - action = action, - ), - ) - } - } - return currentState.copy( reasons = options.map { option -> if (option.id in disabledOptionsIdsDueToEmptyResultingQuotes) { @@ -255,8 +253,6 @@ internal data class TerminationSurveyState( val feedbackText: String?, val showFullScreenEditText: Boolean, val selectedOptionId: String?, - val nextNavigationStep: SurveyNavigationStep?, - val navigationStepLoading: Boolean, val errorWhileLoadingNextStep: Boolean, val showEmptyQuotesDialog: DeflectType? = null, val intentAndIdToRedirectToChangeTierFlow: Pair?, @@ -276,8 +272,6 @@ internal data class TerminationSurveyState( feedbackText = null, showFullScreenEditText = false, selectedOptionId = null, - nextNavigationStep = null, - navigationStepLoading = false, errorWhileLoadingNextStep = false, showEmptyQuotesDialog = null, intentAndIdToRedirectToChangeTierFlow = null, @@ -294,12 +288,46 @@ internal sealed interface DeflectType { ) : DeflectType } -internal sealed interface SurveyNavigationStep { - data class NavigateToNextTerminationStep( - val selectedOption: TerminationSurveyOption, - val feedbackText: String?, - val action: TerminationAction, - ) : SurveyNavigationStep +private fun Backstack.navigateAfterSurvey( + selectedOption: TerminationSurveyOption, + feedbackText: String?, + action: TerminationAction, + commonParams: TerminationGraphParameters, +) { + val suggestion = selectedOption.suggestion + if (suggestion != null && suggestion.type in SuggestionType.DEFLECT_TYPES) { + add( + DeflectSuggestionKey( + description = suggestion.description, + url = suggestion.url, + suggestionType = suggestion.type, + commonParams = commonParams, + action = action, + selectedReasonId = selectedOption.id, + feedbackComment = feedbackText, + ), + ) + return + } + when (val terminationAction = action) { + is TerminationAction.TerminateWithDate -> add( + TerminationDateKey( + minDate = terminationAction.minDate, + maxDate = terminationAction.maxDate, + extraCoverageItems = terminationAction.extraCoverageItems, + commonParams = commonParams, + selectedReasonId = selectedOption.id, + feedbackComment = feedbackText, + ), + ) - data object NavigateToSubOptions : SurveyNavigationStep + is TerminationAction.DeleteInsurance -> add( + InsuranceDeletionKey( + commonParams = commonParams, + extraCoverageItems = terminationAction.extraCoverageItems, + selectedReasonId = selectedOption.id, + feedbackComment = feedbackText, + ), + ) + } } diff --git a/app/feature/feature-terminate-insurance/src/main/kotlin/com/hedvig/android/feature/terminateinsurance/step/terminationreview/TerminationConfirmationDestination.kt b/app/feature/feature-terminate-insurance/src/main/kotlin/com/hedvig/android/feature/terminateinsurance/step/terminationreview/TerminationConfirmationDestination.kt index baa4f7d521..2ced78dd10 100644 --- a/app/feature/feature-terminate-insurance/src/main/kotlin/com/hedvig/android/feature/terminateinsurance/step/terminationreview/TerminationConfirmationDestination.kt +++ b/app/feature/feature-terminate-insurance/src/main/kotlin/com/hedvig/android/feature/terminateinsurance/step/terminationreview/TerminationConfirmationDestination.kt @@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -76,16 +75,10 @@ import org.jetbrains.compose.resources.stringResource internal fun TerminationConfirmationDestination( viewModel: TerminationConfirmationViewModel, onContinue: () -> Unit, - navigateToSuccess: (LocalDate?) -> Unit, navigateUp: () -> Unit, closeTerminationFlow: () -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - LaunchedEffect(uiState.terminationSuccess) { - val success = uiState.terminationSuccess ?: return@LaunchedEffect - navigateToSuccess(success.terminationDate) - } - TerminationConfirmationScreen( uiState = uiState, onContinue = onContinue, @@ -101,9 +94,7 @@ private fun TerminationConfirmationScreen( navigateBack: () -> Unit, closeTerminationFlow: () -> Unit, ) { - val isSubmittingTerminationOrNavigatingForward = - uiState.isSubmittingContractTermination || uiState.terminationSuccess != null - if (isSubmittingTerminationOrNavigatingForward) { + if (uiState.isSubmittingContractTermination) { HedvigFullScreenCenterAlignedLinearProgress( title = stringResource(Res.string.TERMINATE_CONTRACT_TERMINATING_PROGRESS), ) @@ -339,7 +330,6 @@ private fun PreviewTerminationConfirmationScreen( ExtraCoverageItem(displayName = "displayName#$it", displayValue = "displayValue#$it") }, notificationMessage = "Your insurance will be deactivated when you no longer have two insurances with us", - terminationSuccess = null, userError = null, isSubmittingContractTermination = isLoading, ), diff --git a/app/feature/feature-terminate-insurance/src/main/kotlin/com/hedvig/android/feature/terminateinsurance/step/terminationreview/TerminationConfirmationViewModel.kt b/app/feature/feature-terminate-insurance/src/main/kotlin/com/hedvig/android/feature/terminateinsurance/step/terminationreview/TerminationConfirmationViewModel.kt index 18a08ced15..b5ab9a1643 100644 --- a/app/feature/feature-terminate-insurance/src/main/kotlin/com/hedvig/android/feature/terminateinsurance/step/terminationreview/TerminationConfirmationViewModel.kt +++ b/app/feature/feature-terminate-insurance/src/main/kotlin/com/hedvig/android/feature/terminateinsurance/step/terminationreview/TerminationConfirmationViewModel.kt @@ -11,13 +11,17 @@ import com.hedvig.android.feature.terminateinsurance.data.ExtraCoverageItem import com.hedvig.android.feature.terminateinsurance.data.GetTerminationNotificationUseCase import com.hedvig.android.feature.terminateinsurance.data.TerminateInsuranceRepository import com.hedvig.android.feature.terminateinsurance.data.TerminationResult +import com.hedvig.android.feature.terminateinsurance.navigation.TerminateInsuranceKey import com.hedvig.android.feature.terminateinsurance.navigation.TerminationConfirmationKey import com.hedvig.android.feature.terminateinsurance.navigation.TerminationConfirmationKey.TerminationType.Deletion import com.hedvig.android.feature.terminateinsurance.navigation.TerminationConfirmationKey.TerminationType.Termination import com.hedvig.android.feature.terminateinsurance.navigation.TerminationGraphParameters +import com.hedvig.android.feature.terminateinsurance.navigation.TerminationSuccessKey import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel +import com.hedvig.android.navigation.compose.Backstack +import com.hedvig.android.navigation.compose.navigateAndPopUpTo import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject @@ -26,7 +30,6 @@ import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactory import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactoryKey import kotlin.time.Clock import kotlinx.coroutines.launch -import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime @@ -39,13 +42,13 @@ internal class TerminationConfirmationViewModel @AssistedInject constructor( terminateInsuranceRepository: TerminateInsuranceRepository, getTerminationNotificationUseCase: GetTerminationNotificationUseCase, clock: Clock, + backstack: Backstack, ) : MoleculeViewModel( OverviewUiState( terminationType = terminationType, insuranceInfo = insuranceInfo, extraCoverageItems = extraCoverageItems, notificationMessage = null, - terminationSuccess = null, userError = null, isSubmittingContractTermination = false, ), @@ -57,6 +60,7 @@ internal class TerminationConfirmationViewModel @AssistedInject constructor( terminateInsuranceRepository, getTerminationNotificationUseCase, clock, + backstack, ), ) { @AssistedFactory @@ -75,8 +79,6 @@ internal class TerminationConfirmationViewModel @AssistedInject constructor( sealed interface TerminationConfirmationEvent { data object Submit : TerminationConfirmationEvent - - data object HandledNavigation : TerminationConfirmationEvent } internal class TerminationConfirmationPresenter( @@ -87,6 +89,7 @@ internal class TerminationConfirmationPresenter( private val terminateInsuranceRepository: TerminateInsuranceRepository, private val getTerminationNotificationUseCase: GetTerminationNotificationUseCase, private val clock: Clock, + private val backstack: Backstack, ) : MoleculePresenter { @Composable override fun MoleculePresenterScope.present( @@ -109,10 +112,6 @@ internal class TerminationConfirmationPresenter( CollectEvents { event -> when (event) { - TerminationConfirmationEvent.HandledNavigation -> { - uiState = uiState.copy(terminationSuccess = null, userError = null) - } - TerminationConfirmationEvent.Submit -> { uiState = uiState.copy(isSubmittingContractTermination = true, userError = null) launch { @@ -144,14 +143,14 @@ internal class TerminationConfirmationPresenter( userError = terminationResult.message, ) - is TerminationResult.Terminated -> uiState = uiState.copy( - isSubmittingContractTermination = false, - terminationSuccess = TerminationSuccessResult(terminationResult.terminationDate), + is TerminationResult.Terminated -> backstack.navigateAndPopUpTo( + TerminationSuccessKey(terminationResult.terminationDate), + inclusive = true, ) - TerminationResult.Deleted -> uiState = uiState.copy( - isSubmittingContractTermination = false, - terminationSuccess = TerminationSuccessResult(terminationDate = null), + TerminationResult.Deleted -> backstack.navigateAndPopUpTo( + TerminationSuccessKey(terminationDate = null), + inclusive = true, ) } }, @@ -165,14 +164,11 @@ internal class TerminationConfirmationPresenter( } } -internal data class TerminationSuccessResult(val terminationDate: LocalDate?) - internal data class OverviewUiState( val terminationType: TerminationConfirmationKey.TerminationType, val insuranceInfo: TerminationGraphParameters, val extraCoverageItems: List, val notificationMessage: String?, - val terminationSuccess: TerminationSuccessResult?, val userError: String?, val isSubmittingContractTermination: Boolean, ) diff --git a/app/feature/feature-terminate-insurance/src/test/kotlin/com/hedvig/android/feature/terminateinsurance/TestBackstack.kt b/app/feature/feature-terminate-insurance/src/test/kotlin/com/hedvig/android/feature/terminateinsurance/TestBackstack.kt new file mode 100644 index 0000000000..b91e7b9e8d --- /dev/null +++ b/app/feature/feature-terminate-insurance/src/test/kotlin/com/hedvig/android/feature/terminateinsurance/TestBackstack.kt @@ -0,0 +1,8 @@ +package com.hedvig.android.feature.terminateinsurance + +import com.hedvig.android.navigation.common.HedvigNavKey +import com.hedvig.android.navigation.compose.Backstack + +internal class TestBackstack( + override val entries: MutableList = mutableListOf(), +) : Backstack diff --git a/app/feature/feature-terminate-insurance/src/test/kotlin/com/hedvig/android/feature/terminateinsurance/step/survey/TerminationSurveyPresenterTest.kt b/app/feature/feature-terminate-insurance/src/test/kotlin/com/hedvig/android/feature/terminateinsurance/step/survey/TerminationSurveyPresenterTest.kt index 2412f954dd..1659b8e997 100644 --- a/app/feature/feature-terminate-insurance/src/test/kotlin/com/hedvig/android/feature/terminateinsurance/step/survey/TerminationSurveyPresenterTest.kt +++ b/app/feature/feature-terminate-insurance/src/test/kotlin/com/hedvig/android/feature/terminateinsurance/step/survey/TerminationSurveyPresenterTest.kt @@ -13,6 +13,7 @@ import assertk.assertions.isNotNull import assertk.assertions.isNull import assertk.assertions.isSameInstanceAs import assertk.assertions.isTrue +import assertk.assertions.prop import com.hedvig.android.core.common.ErrorMessage import com.hedvig.android.core.uidata.UiCurrencyCode.SEK import com.hedvig.android.core.uidata.UiMoney @@ -29,11 +30,15 @@ import com.hedvig.android.data.changetier.data.TotalCost import com.hedvig.android.data.contract.ContractGroup.RENTAL import com.hedvig.android.data.contract.ContractType.SE_APARTMENT_RENT import com.hedvig.android.data.productvariant.ProductVariant +import com.hedvig.android.feature.terminateinsurance.TestBackstack import com.hedvig.android.feature.terminateinsurance.data.ExtraCoverageItem import com.hedvig.android.feature.terminateinsurance.data.SuggestionType import com.hedvig.android.feature.terminateinsurance.data.SurveyOptionSuggestion import com.hedvig.android.feature.terminateinsurance.data.TerminationAction import com.hedvig.android.feature.terminateinsurance.data.TerminationSurveyOption +import com.hedvig.android.feature.terminateinsurance.navigation.TerminationDateKey +import com.hedvig.android.feature.terminateinsurance.navigation.TerminationGraphParameters +import com.hedvig.android.feature.terminateinsurance.navigation.TerminationSurveySecondStepKey import com.hedvig.android.logger.TestLogcatLoggingRule import com.hedvig.android.molecule.test.test import kotlinx.coroutines.test.runTest @@ -57,6 +62,13 @@ class TerminationSurveyPresenterTest { extraCoverageItems = emptyList(), ) + private val testCommonParams = TerminationGraphParameters( + contractId = "contractId", + insuranceDisplayName = "displayName", + exposureName = "exposure", + contractGroup = RENTAL, + ) + private val listOfOptionsForHome = listOf( TerminationSurveyOption( id = "id1", @@ -105,8 +117,9 @@ class TerminationSurveyPresenterTest { val presenter = TerminationSurveyPresenter( options = listOfOptionsForHome, action = testAction, + commonParams = testCommonParams, changeTierRepository = changeTierRepository, - contractId = "contractId", + backstack = TestBackstack(), ) presenter.test(initialState = TerminationSurveyState(listOfOptionsForHome)) { assertThat(awaitItem().reasons).isEqualTo(listOfOptionsForHome) @@ -123,8 +136,9 @@ class TerminationSurveyPresenterTest { val presenter = TerminationSurveyPresenter( listOfOptionsForHome, testAction, + testCommonParams, changeTierRepository, - "contractId", + TestBackstack(), ) presenter.test(initialState = TerminationSurveyState(listOfOptionsForHome)) { skipItems(1) @@ -143,8 +157,9 @@ class TerminationSurveyPresenterTest { val presenter = TerminationSurveyPresenter( listOfOptionsForHome, testAction, + testCommonParams, changeTierRepository, - "contractId", + TestBackstack(), ) presenter.test(initialState = TerminationSurveyState(listOfOptionsForHome)) { assertThat(awaitItem().reasons).isEqualTo(listOfOptionsForHome) @@ -157,8 +172,9 @@ class TerminationSurveyPresenterTest { val presenter = TerminationSurveyPresenter( listOfOptionsForHome, testAction, + testCommonParams, changeTierRepository, - "contractId", + TestBackstack(), ) presenter.test(initialState = TerminationSurveyState(listOfOptionsForHome)) { skipItems(1) @@ -176,11 +192,14 @@ class TerminationSurveyPresenterTest { @Test fun `when survey is submitted for option with no subOptions navigate to next termination step`() = runTest { val changeTierRepository = FakeChangeTierRepository() + val backstack = TestBackstack() + val scheduler = testScheduler val presenter = TerminationSurveyPresenter( - listOfOptionsForHome, - testAction, - changeTierRepository, - "contractId", + options = listOfOptionsForHome, + action = testAction, + commonParams = testCommonParams, + changeTierRepository = changeTierRepository, + backstack = backstack, ) presenter.test(initialState = TerminationSurveyState(listOfOptionsForHome)) { skipItems(1) @@ -189,31 +208,35 @@ class TerminationSurveyPresenterTest { sendEvent(TerminationSurveyEvent.EditTextFeedback("my feedback")) skipItems(1) sendEvent(TerminationSurveyEvent.Continue) - val result = awaitItem() - assertThat(result.nextNavigationStep).isNotNull() - .isInstanceOf() - val navStep = result.nextNavigationStep as SurveyNavigationStep.NavigateToNextTerminationStep - assertThat(navStep.selectedOption).isEqualTo(listOfOptionsForHome[2]) - assertThat(navStep.feedbackText).isEqualTo("my feedback") - assertThat(navStep.action).isEqualTo(testAction) + scheduler.advanceUntilIdle() + assertThat(backstack.entries.last()) + .isInstanceOf() + .prop(TerminationDateKey::selectedReasonId) + .isEqualTo(listOfOptionsForHome[2].id) + cancelAndIgnoreRemainingEvents() } } @Test fun `when survey is submitted for option with subOptions navigate to next survey screen`() = runTest { val changeTierRepository = FakeChangeTierRepository() + val backstack = TestBackstack() + val scheduler = testScheduler val presenter = TerminationSurveyPresenter( - listOfOptionsForHome, - testAction, - changeTierRepository, - "contractId", + options = listOfOptionsForHome, + action = testAction, + commonParams = testCommonParams, + changeTierRepository = changeTierRepository, + backstack = backstack, ) presenter.test(initialState = TerminationSurveyState(listOfOptionsForHome)) { skipItems(1) sendEvent(TerminationSurveyEvent.SelectOption(listOfOptionsForHome[1])) skipItems(1) sendEvent(TerminationSurveyEvent.Continue) - assertThat(awaitItem().nextNavigationStep).isEqualTo(SurveyNavigationStep.NavigateToSubOptions) + scheduler.advanceUntilIdle() + assertThat(backstack.entries.last()).isInstanceOf() + cancelAndIgnoreRemainingEvents() } } @@ -223,8 +246,9 @@ class TerminationSurveyPresenterTest { val presenter = TerminationSurveyPresenter( listOfOptionsForHome, testAction, + testCommonParams, changeTierRepository, - "contractId", + TestBackstack(), ) presenter.test(initialState = TerminationSurveyState(listOfOptionsForHome)) { skipItems(1) @@ -243,8 +267,9 @@ class TerminationSurveyPresenterTest { val presenter = TerminationSurveyPresenter( listOfOptionsForHome, testAction, + testCommonParams, changeTierRepository, - "contractId", + TestBackstack(), ) presenter.test(initialState = TerminationSurveyState(listOfOptionsForHome)) { skipItems(1) @@ -278,8 +303,9 @@ class TerminationSurveyPresenterTest { val presenter = TerminationSurveyPresenter( listOfOptionsForHome, testAction, + testCommonParams, changeTierRepository, - "contractId", + TestBackstack(), ) presenter.test(initialState = TerminationSurveyState(listOfOptionsForHome)) { sendEvent(TerminationSurveyEvent.SelectOption(listOfOptionsForHome[3])) @@ -308,8 +334,9 @@ class TerminationSurveyPresenterTest { val presenter = TerminationSurveyPresenter( listOfOptionsForHome, testAction, + testCommonParams, changeTierRepository, - "contractId", + TestBackstack(), ) presenter.test(initialState = TerminationSurveyState(listOfOptionsForHome)) { sendEvent(TerminationSurveyEvent.SelectOption(listOfOptionsForHome[3])) @@ -332,8 +359,9 @@ class TerminationSurveyPresenterTest { val presenter = TerminationSurveyPresenter( listOfOptionsForHome, testAction, + testCommonParams, changeTierRepository, - "contractId", + TestBackstack(), ) presenter.test(initialState = TerminationSurveyState(listOfOptionsForHome)) { sendEvent(TerminationSurveyEvent.SelectOption(listOfOptionsForHome[3])) @@ -366,8 +394,9 @@ class TerminationSurveyPresenterTest { val presenter = TerminationSurveyPresenter( listOfOptionsForHome, testAction, + testCommonParams, changeTierRepository, - "contractId", + TestBackstack(), ) presenter.test(initialState = TerminationSurveyState(listOfOptionsForHome)) { sendEvent(TerminationSurveyEvent.SelectOption(listOfOptionsForHome[3])) diff --git a/app/feature/feature-terminate-insurance/src/test/kotlin/com/hedvig/android/feature/terminateinsurance/step/terminationreview/TerminationConfirmationPresenterTest.kt b/app/feature/feature-terminate-insurance/src/test/kotlin/com/hedvig/android/feature/terminateinsurance/step/terminationreview/TerminationConfirmationPresenterTest.kt index b93a48981e..7af6627ef1 100644 --- a/app/feature/feature-terminate-insurance/src/test/kotlin/com/hedvig/android/feature/terminateinsurance/step/terminationreview/TerminationConfirmationPresenterTest.kt +++ b/app/feature/feature-terminate-insurance/src/test/kotlin/com/hedvig/android/feature/terminateinsurance/step/terminationreview/TerminationConfirmationPresenterTest.kt @@ -6,18 +6,22 @@ import arrow.core.right import assertk.assertThat import assertk.assertions.isEqualTo import assertk.assertions.isFalse -import assertk.assertions.isNotNull +import assertk.assertions.isInstanceOf import assertk.assertions.isNull -import assertk.assertions.isTrue +import assertk.assertions.prop import com.hedvig.android.core.common.ErrorMessage import com.hedvig.android.data.contract.ContractGroup.HOMEOWNER +import com.hedvig.android.feature.terminateinsurance.TestBackstack import com.hedvig.android.feature.terminateinsurance.data.TerminateInsuranceRepository import com.hedvig.android.feature.terminateinsurance.data.TerminationResult import com.hedvig.android.feature.terminateinsurance.data.TerminationSurveyData +import com.hedvig.android.feature.terminateinsurance.navigation.TerminateInsuranceKey import com.hedvig.android.feature.terminateinsurance.navigation.TerminationConfirmationKey.TerminationType import com.hedvig.android.feature.terminateinsurance.navigation.TerminationGraphParameters +import com.hedvig.android.feature.terminateinsurance.navigation.TerminationSuccessKey import com.hedvig.android.logger.TestLogcatLoggingRule import com.hedvig.android.molecule.test.test +import com.hedvig.android.navigation.compose.Backstack import kotlin.time.Clock import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf @@ -44,7 +48,6 @@ class TerminationConfirmationPresenterTest { insuranceInfo = testInsuranceInfo, extraCoverageItems = emptyList(), notificationMessage = null, - terminationSuccess = null, userError = null, isSubmittingContractTermination = false, ) @@ -54,18 +57,22 @@ class TerminationConfirmationPresenterTest { val repository = FakeTerminateInsuranceRepository( terminateResult = TerminationResult.Terminated(terminationDate).right(), ) + val backstack = TestBackstack(mutableListOf(TerminateInsuranceKey())) + val scheduler = testScheduler val presenter = createPresenter( terminationType = TerminationType.Termination(terminationDate), repository = repository, + backstack = backstack, ) presenter.test(initialState = initialState(TerminationType.Termination(terminationDate))) { skipItems(1) sendEvent(TerminationConfirmationEvent.Submit) - val states = cancelAndConsumeRemainingEvents().filterIsInstance>() - val lastState = states.last().value - assertThat(lastState.terminationSuccess).isNotNull() - assertThat(lastState.terminationSuccess!!.terminationDate).isEqualTo(terminationDate) - assertThat(lastState.userError).isNull() + scheduler.advanceUntilIdle() + assertThat(backstack.entries.last()) + .isInstanceOf() + .prop(TerminationSuccessKey::terminationDate) + .isEqualTo(terminationDate) + cancelAndIgnoreRemainingEvents() } } @@ -74,17 +81,22 @@ class TerminationConfirmationPresenterTest { val repository = FakeTerminateInsuranceRepository( deleteResult = TerminationResult.Deleted.right(), ) + val backstack = TestBackstack(mutableListOf(TerminateInsuranceKey())) + val scheduler = testScheduler val presenter = createPresenter( terminationType = TerminationType.Deletion, repository = repository, + backstack = backstack, ) presenter.test(initialState = initialState(TerminationType.Deletion)) { skipItems(1) sendEvent(TerminationConfirmationEvent.Submit) - val states = cancelAndConsumeRemainingEvents().filterIsInstance>() - val lastState = states.last().value - assertThat(lastState.terminationSuccess).isNotNull() - assertThat(lastState.terminationSuccess!!.terminationDate).isNull() + scheduler.advanceUntilIdle() + assertThat(backstack.entries.last()) + .isInstanceOf() + .prop(TerminationSuccessKey::terminationDate) + .isNull() + cancelAndIgnoreRemainingEvents() } } @@ -93,9 +105,11 @@ class TerminationConfirmationPresenterTest { val repository = FakeTerminateInsuranceRepository( terminateResult = TerminationResult.UserError("Cannot terminate this contract").right(), ) + val backstack = TestBackstack(mutableListOf(TerminateInsuranceKey())) val presenter = createPresenter( terminationType = TerminationType.Termination(terminationDate), repository = repository, + backstack = backstack, ) presenter.test(initialState = initialState(TerminationType.Termination(terminationDate))) { skipItems(1) @@ -103,8 +117,8 @@ class TerminationConfirmationPresenterTest { val states = cancelAndConsumeRemainingEvents().filterIsInstance>() val lastState = states.last().value assertThat(lastState.userError).isEqualTo("Cannot terminate this contract") - assertThat(lastState.terminationSuccess).isNull() assertThat(lastState.isSubmittingContractTermination).isFalse() + assertThat(backstack.entries.last()).isInstanceOf() } } @@ -113,9 +127,11 @@ class TerminationConfirmationPresenterTest { val repository = FakeTerminateInsuranceRepository( terminateResult = ErrorMessage("Network error").left(), ) + val backstack = TestBackstack(mutableListOf(TerminateInsuranceKey())) val presenter = createPresenter( terminationType = TerminationType.Termination(terminationDate), repository = repository, + backstack = backstack, ) presenter.test(initialState = initialState(TerminationType.Termination(terminationDate))) { skipItems(1) @@ -123,20 +139,24 @@ class TerminationConfirmationPresenterTest { val states = cancelAndConsumeRemainingEvents().filterIsInstance>() val lastState = states.last().value assertThat(lastState.userError).isEqualTo("Network error") - assertThat(lastState.terminationSuccess).isNull() + assertThat(backstack.entries.last()).isInstanceOf() } } - private fun createPresenter(terminationType: TerminationType, repository: TerminateInsuranceRepository) = - TerminationConfirmationPresenter( - terminationType = terminationType, - insuranceInfo = testInsuranceInfo, - selectedReasonId = "reason1", - feedbackComment = null, - terminateInsuranceRepository = repository, - getTerminationNotificationUseCase = FakeGetTerminationNotificationUseCase(), - clock = Clock.System, - ) + private fun createPresenter( + terminationType: TerminationType, + repository: TerminateInsuranceRepository, + backstack: Backstack, + ) = TerminationConfirmationPresenter( + terminationType = terminationType, + insuranceInfo = testInsuranceInfo, + selectedReasonId = "reason1", + feedbackComment = null, + terminateInsuranceRepository = repository, + getTerminationNotificationUseCase = FakeGetTerminationNotificationUseCase(), + clock = Clock.System, + backstack = backstack, + ) } private class FakeTerminateInsuranceRepository( diff --git a/app/feature/feature-travel-certificate/src/main/kotlin/com/hedvig/android/feature/travelcertificate/navigation/TravelCertificateEntries.kt b/app/feature/feature-travel-certificate/src/main/kotlin/com/hedvig/android/feature/travelcertificate/navigation/TravelCertificateEntries.kt index bb227ffd44..e549d0e171 100644 --- a/app/feature/feature-travel-certificate/src/main/kotlin/com/hedvig/android/feature/travelcertificate/navigation/TravelCertificateEntries.kt +++ b/app/feature/feature-travel-certificate/src/main/kotlin/com/hedvig/android/feature/travelcertificate/navigation/TravelCertificateEntries.kt @@ -16,7 +16,6 @@ import com.hedvig.android.feature.travelcertificate.ui.overview.TravelCertificat import com.hedvig.android.navigation.common.HedvigNavKey import com.hedvig.android.navigation.compose.Backstack import com.hedvig.android.navigation.compose.add -import com.hedvig.android.navigation.compose.navigateAndPopUpTo import com.hedvig.core.common.android.sharePDF import dev.zacsweers.metrox.viewmodel.assistedMetroViewModel import dev.zacsweers.metrox.viewmodel.metroViewModel @@ -67,19 +66,6 @@ fun EntryProviderScope.travelCertificateEntries( TravelCertificateDateInputDestination( viewModel = viewModel, navigateUp = backstack::navigateUp, - onNavigateToFellowTravellers = { travelCertificatePrimaryInput -> - backstack.add( - TravelCertificateTravellersInputKey( - travelCertificatePrimaryInput, - ), - ) - }, - onNavigateToOverview = { travelCertificateUrl -> - backstack.navigateAndPopUpTo( - ShowCertificateKey(travelCertificateUrl), - inclusive = false, - ) - }, ) } @@ -95,12 +81,6 @@ fun EntryProviderScope.travelCertificateEntries( TravelCertificateTravellersInputDestination( viewModel = viewModel, navigateUp = backstack::navigateUp, - onNavigateToOverview = { travelCertificateUrl -> - backstack.navigateAndPopUpTo( - ShowCertificateKey(travelCertificateUrl), - inclusive = false, - ) - }, onNavigateToCoInsuredAddInfo = { onNavigateToCoInsuredAddInfo(primaryInput.contractId) }, ) } diff --git a/app/feature/feature-travel-certificate/src/main/kotlin/com/hedvig/android/feature/travelcertificate/ui/generatewhen/TravelCertificateDateInput.kt b/app/feature/feature-travel-certificate/src/main/kotlin/com/hedvig/android/feature/travelcertificate/ui/generatewhen/TravelCertificateDateInput.kt index da4ba81638..a60882b893 100644 --- a/app/feature/feature-travel-certificate/src/main/kotlin/com/hedvig/android/feature/travelcertificate/ui/generatewhen/TravelCertificateDateInput.kt +++ b/app/feature/feature-travel-certificate/src/main/kotlin/com/hedvig/android/feature/travelcertificate/ui/generatewhen/TravelCertificateDateInput.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -37,8 +36,6 @@ import com.hedvig.android.design.system.hedvig.api.HedvigDatePickerState import com.hedvig.android.design.system.hedvig.api.HedvigDisplayMode import com.hedvig.android.design.system.hedvig.datepicker.HedvigDatePicker import com.hedvig.android.design.system.hedvig.datepicker.HedvigDatePickerState -import com.hedvig.android.feature.travelcertificate.data.TravelCertificateUrl -import com.hedvig.android.feature.travelcertificate.navigation.TravelCertificateTravellersInputKey import com.hedvig.android.feature.travelcertificate.ui.generatewhen.TravelCertificateDateInputUiState.Success import hedvig.resources.PROFILE_MY_INFO_EMAIL_LABEL import hedvig.resources.Res @@ -53,20 +50,13 @@ import org.jetbrains.compose.resources.stringResource internal fun TravelCertificateDateInputDestination( viewModel: TravelCertificateDateInputViewModel, navigateUp: () -> Unit, - onNavigateToFellowTravellers: ( - TravelCertificateTravellersInputKey.TravelCertificatePrimaryInput, - ) -> Unit, - onNavigateToOverview: (TravelCertificateUrl) -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() TravelCertificateDateInput( uiState = uiState, reload = { viewModel.emit(TravelCertificateDateInputEvent.RetryLoadData) }, navigateUp = navigateUp, - onNavigateToFellowTravellers = onNavigateToFellowTravellers, - onNavigateToOverview = onNavigateToOverview, submitInput = { viewModel.emit(TravelCertificateDateInputEvent.Submit) }, - nullifyPrimaryInput = { viewModel.emit(TravelCertificateDateInputEvent.NullifyPrimaryInput) }, onEmailChanged = { viewModel.emit(TravelCertificateDateInputEvent.ChangeEmailInput(it)) }, ) } @@ -77,12 +67,7 @@ private fun TravelCertificateDateInput( onEmailChanged: (String) -> Unit, reload: () -> Unit, navigateUp: () -> Unit, - onNavigateToFellowTravellers: ( - TravelCertificateTravellersInputKey.TravelCertificatePrimaryInput, - ) -> Unit, - onNavigateToOverview: (TravelCertificateUrl) -> Unit, submitInput: () -> Unit, - nullifyPrimaryInput: () -> Unit, ) { when (uiState) { TravelCertificateDateInputUiState.Failure -> { @@ -97,22 +82,7 @@ private fun TravelCertificateDateInput( HedvigFullScreenCenterAlignedProgress() } - is TravelCertificateDateInputUiState.UrlFetched -> { - LaunchedEffect(Unit) { - onNavigateToOverview(uiState.travelCertificateUrl) - } - } - is Success -> { - LaunchedEffect(uiState.primaryInput) { - if (uiState.errorMessageRes == null) { - if (uiState.primaryInput != null) { - onNavigateToFellowTravellers(uiState.primaryInput) - nullifyPrimaryInput() - } - } - } - var emailInput by remember { mutableStateOf(uiState.email ?: "") } @@ -237,9 +207,6 @@ private fun PreviewTravelCertificateDateInput( {}, {}, {}, - {}, - {}, - {}, ) } } @@ -256,10 +223,8 @@ private class ChooseInsuranceForAddonUiStateProvider : travelDate = LocalDate(2023, 1, 1), daysValid = 40, errorMessageRes = null, - primaryInput = null, ), TravelCertificateDateInputUiState.Failure, TravelCertificateDateInputUiState.Loading, - TravelCertificateDateInputUiState.UrlFetched(TravelCertificateUrl("")), ), ) diff --git a/app/feature/feature-travel-certificate/src/main/kotlin/com/hedvig/android/feature/travelcertificate/ui/generatewhen/TravelCertificateDateInputViewModel.kt b/app/feature/feature-travel-certificate/src/main/kotlin/com/hedvig/android/feature/travelcertificate/ui/generatewhen/TravelCertificateDateInputViewModel.kt index 3ba2341d37..b1a9aca927 100644 --- a/app/feature/feature-travel-certificate/src/main/kotlin/com/hedvig/android/feature/travelcertificate/ui/generatewhen/TravelCertificateDateInputViewModel.kt +++ b/app/feature/feature-travel-certificate/src/main/kotlin/com/hedvig/android/feature/travelcertificate/ui/generatewhen/TravelCertificateDateInputViewModel.kt @@ -13,14 +13,16 @@ import com.hedvig.android.design.system.hedvig.api.HedvigSelectableDates import com.hedvig.android.design.system.hedvig.datepicker.HedvigDatePickerState import com.hedvig.android.feature.travelcertificate.data.CreateTravelCertificateUseCase import com.hedvig.android.feature.travelcertificate.data.GetTravelCertificateSpecificationsUseCase -import com.hedvig.android.feature.travelcertificate.data.TravelCertificateUrl +import com.hedvig.android.feature.travelcertificate.navigation.ShowCertificateKey +import com.hedvig.android.feature.travelcertificate.navigation.TravelCertificateKey import com.hedvig.android.feature.travelcertificate.navigation.TravelCertificateTravellersInputKey import com.hedvig.android.language.LanguageService -import com.hedvig.android.logger.LogPriority -import com.hedvig.android.logger.logcat import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel +import com.hedvig.android.navigation.compose.Backstack +import com.hedvig.android.navigation.compose.add +import com.hedvig.android.navigation.compose.navigateAndPopUpTo import com.hedvig.core.common.android.validation.validateEmail import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory @@ -47,6 +49,7 @@ internal class TravelCertificateDateInputViewModel( getTravelCertificateSpecificationsUseCase: GetTravelCertificateSpecificationsUseCase, createTravelCertificateUseCase: CreateTravelCertificateUseCase, languageService: LanguageService, + backstack: Backstack, ) : MoleculeViewModel( initialState = TravelCertificateDateInputUiState.Loading, presenter = TravelCertificateDateInputPresenter( @@ -54,6 +57,7 @@ internal class TravelCertificateDateInputViewModel( getTravelCertificateSpecificationsUseCase, createTravelCertificateUseCase, languageService, + backstack, ), sharingStarted = SharingStarted.WhileSubscribed(5.seconds), ) { @@ -72,6 +76,7 @@ internal class TravelCertificateDateInputPresenter( private val getTravelCertificateSpecificationsUseCase: GetTravelCertificateSpecificationsUseCase, private val createTravelCertificateUseCase: CreateTravelCertificateUseCase, private val languageService: LanguageService, + private val backstack: Backstack, ) : MoleculePresenter { @Composable override fun MoleculePresenterScope.present( @@ -103,19 +108,10 @@ internal class TravelCertificateDateInputPresenter( TravelCertificateDateInputUiState.Loading -> { DateInputScreenContent.Loading } - - is TravelCertificateDateInputUiState.UrlFetched -> { - logcat(LogPriority.ERROR) { "TravelCertificateDateInputUiState is UrlFetched, should be impossible" } - DateInputScreenContent.Loading - } }, ) } - var primaryInput by remember { - mutableStateOf(null) - } - var invalidEmailErrorMessage by remember { mutableStateOf(null) } CollectEvents { event -> @@ -128,13 +124,15 @@ internal class TravelCertificateDateInputPresenter( ).isSuccessful ) { if (successScreenContent.details.hasCoInsured) { - val travelCertificatePrimaryInput = - TravelCertificateTravellersInputKey.TravelCertificatePrimaryInput( - successScreenContent.details.email, - successScreenContent.details.travelDate, - successScreenContent.details.contractId, - ) - primaryInput = travelCertificatePrimaryInput + backstack.add( + TravelCertificateTravellersInputKey( + TravelCertificateTravellersInputKey.TravelCertificatePrimaryInput( + successScreenContent.details.email, + successScreenContent.details.travelDate, + successScreenContent.details.contractId, + ), + ), + ) } else { createTravelCertificateData = CreateTravelCertificateData( successScreenContent.details.contractId, @@ -167,10 +165,6 @@ internal class TravelCertificateDateInputPresenter( loadIteration++ } - TravelCertificateDateInputEvent.NullifyPrimaryInput -> { - primaryInput = null - } - is TravelCertificateDateInputEvent.Submit -> { validateInputAndContinue() } @@ -191,7 +185,7 @@ internal class TravelCertificateDateInputPresenter( screenContent = DateInputScreenContent.Failure }, ifRight = { url -> - screenContent = DateInputScreenContent.UrlFetched(url) + backstack.navigateAndPopUpTo(ShowCertificateKey(url), inclusive = false) }, ) createTravelCertificateData = null @@ -255,14 +249,9 @@ internal class TravelCertificateDateInputPresenter( hasCoInsured = currentContent.details.hasCoInsured, datePickerState = currentContent.details.datePickerState, daysValid = currentContent.details.daysValid, - primaryInput = primaryInput, errorMessageRes = invalidEmailErrorMessage, ) } - - is DateInputScreenContent.UrlFetched -> { - TravelCertificateDateInputUiState.UrlFetched(currentContent.travelCertificateUrl) - } } } } @@ -288,8 +277,6 @@ private sealed interface DateInputScreenContent { } ?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date } } - - data class UrlFetched(val travelCertificateUrl: TravelCertificateUrl) : DateInputScreenContent } internal sealed interface TravelCertificateDateInputEvent { @@ -298,8 +285,6 @@ internal sealed interface TravelCertificateDateInputEvent { data class ChangeEmailInput(val email: String) : TravelCertificateDateInputEvent data object Submit : TravelCertificateDateInputEvent - - data object NullifyPrimaryInput : TravelCertificateDateInputEvent } internal sealed interface TravelCertificateDateInputUiState { @@ -307,8 +292,6 @@ internal sealed interface TravelCertificateDateInputUiState { data object Failure : TravelCertificateDateInputUiState - data class UrlFetched(val travelCertificateUrl: TravelCertificateUrl) : TravelCertificateDateInputUiState - data class Success( val contractId: String, val email: String?, @@ -316,7 +299,6 @@ internal sealed interface TravelCertificateDateInputUiState { val hasCoInsured: Boolean, val datePickerState: HedvigDatePickerState, val daysValid: Int, - val primaryInput: TravelCertificateTravellersInputKey.TravelCertificatePrimaryInput?, val errorMessageRes: StringResource?, ) : TravelCertificateDateInputUiState } 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 bfe977f1a5..2796798c9b 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 @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -29,7 +28,6 @@ import com.hedvig.android.design.system.hedvig.RadioOption import com.hedvig.android.design.system.hedvig.RadioOptionId import com.hedvig.android.design.system.hedvig.Surface import com.hedvig.android.design.system.hedvig.a11y.FlowHeading -import com.hedvig.android.feature.travelcertificate.data.TravelCertificateUrl import com.hedvig.android.feature.travelcertificate.ui.generatewho.TravelCertificateTravellersInputUiState.Failure import com.hedvig.android.feature.travelcertificate.ui.generatewho.TravelCertificateTravellersInputUiState.Success import hedvig.resources.GENERAL_SUBMIT @@ -43,7 +41,6 @@ import org.jetbrains.compose.resources.stringResource internal fun TravelCertificateTravellersInputDestination( viewModel: TravelCertificateTravellersInputViewModel, navigateUp: () -> Unit, - onNavigateToOverview: (TravelCertificateUrl) -> Unit, onNavigateToCoInsuredAddInfo: () -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -51,7 +48,6 @@ internal fun TravelCertificateTravellersInputDestination( uiState = uiState, navigateUp = navigateUp, reload = { viewModel.emit(TravelCertificateTravellersInputEvent.RetryLoadData) }, - onNavigateToOverview = onNavigateToOverview, changeCoInsuredChecked = { viewModel.emit(TravelCertificateTravellersInputEvent.ChangeCoInsuredChecked(it)) }, changeMemberChecked = { viewModel.emit(TravelCertificateTravellersInputEvent.ChangeMemberChecked) }, onNavigateToCoInsuredAddInfo = onNavigateToCoInsuredAddInfo, @@ -64,7 +60,6 @@ private fun TravelCertificateTravellersInput( uiState: TravelCertificateTravellersInputUiState, navigateUp: () -> Unit, reload: () -> Unit, - onNavigateToOverview: (TravelCertificateUrl) -> Unit, changeCoInsuredChecked: (CoInsured) -> Unit, changeMemberChecked: () -> Unit, onNavigateToCoInsuredAddInfo: () -> Unit, @@ -84,12 +79,6 @@ private fun TravelCertificateTravellersInput( } } - is TravelCertificateTravellersInputUiState.UrlFetched -> { - LaunchedEffect(Unit) { - onNavigateToOverview(uiState.travelCertificateUrl) - } - } - is Success -> { HedvigScaffold( navigateUp = navigateUp, @@ -189,7 +178,6 @@ private fun PreviewTravelCertificateTravellersInput() { {}, {}, {}, - {}, ) } } @@ -208,7 +196,6 @@ private fun PreviewTravelCertificateTravellersInputWithEmailFailure() { {}, {}, {}, - {}, ) } } diff --git a/app/feature/feature-travel-certificate/src/main/kotlin/com/hedvig/android/feature/travelcertificate/ui/generatewho/TravelCertificateTravellersInputViewModel.kt b/app/feature/feature-travel-certificate/src/main/kotlin/com/hedvig/android/feature/travelcertificate/ui/generatewho/TravelCertificateTravellersInputViewModel.kt index 11e227d9ca..8c20cff545 100644 --- a/app/feature/feature-travel-certificate/src/main/kotlin/com/hedvig/android/feature/travelcertificate/ui/generatewho/TravelCertificateTravellersInputViewModel.kt +++ b/app/feature/feature-travel-certificate/src/main/kotlin/com/hedvig/android/feature/travelcertificate/ui/generatewho/TravelCertificateTravellersInputViewModel.kt @@ -14,12 +14,15 @@ import com.hedvig.android.core.common.di.AppScope import com.hedvig.android.feature.travelcertificate.data.CoInsuredData import com.hedvig.android.feature.travelcertificate.data.CreateTravelCertificateUseCase import com.hedvig.android.feature.travelcertificate.data.GetCoInsuredForContractUseCase -import com.hedvig.android.feature.travelcertificate.data.TravelCertificateUrl +import com.hedvig.android.feature.travelcertificate.navigation.ShowCertificateKey +import com.hedvig.android.feature.travelcertificate.navigation.TravelCertificateKey import com.hedvig.android.feature.travelcertificate.navigation.TravelCertificateTravellersInputKey import com.hedvig.android.feature.travelcertificate.ui.generatewho.CoInsured.CoInsuredId import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel +import com.hedvig.android.navigation.compose.Backstack +import com.hedvig.android.navigation.compose.navigateAndPopUpTo import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject @@ -35,12 +38,14 @@ internal class TravelCertificateTravellersInputViewModel( @Assisted primaryInput: TravelCertificateTravellersInputKey.TravelCertificatePrimaryInput, createTravelCertificateUseCase: CreateTravelCertificateUseCase, getCoInsuredForContractUseCase: GetCoInsuredForContractUseCase, + backstack: Backstack, ) : MoleculeViewModel( initialState = TravelCertificateTravellersInputUiState.Loading, presenter = TravelCertificateTravellersInputPresenter( primaryInput, createTravelCertificateUseCase, getCoInsuredForContractUseCase, + backstack, ), ) { @AssistedFactory @@ -58,6 +63,7 @@ internal class TravelCertificateTravellersInputPresenter( private val primaryInput: TravelCertificateTravellersInputKey.TravelCertificatePrimaryInput, private val createTravelCertificateUseCase: CreateTravelCertificateUseCase, private val getCoInsuredForContractUseCase: GetCoInsuredForContractUseCase, + private val backstack: Backstack, ) : MoleculePresenter { @Composable override fun MoleculePresenterScope.present( @@ -148,7 +154,7 @@ internal class TravelCertificateTravellersInputPresenter( TravelersInputScreenContent.Failure }, ifRight = { url -> - screenContent = TravelersInputScreenContent.UrlFetched(url) + backstack.navigateAndPopUpTo(ShowCertificateKey(url), inclusive = false) }, ) } @@ -172,12 +178,6 @@ internal class TravelCertificateTravellersInputPresenter( isButtonLoading = screenContentValue.isButtonLoading, ) } - - is TravelersInputScreenContent.UrlFetched -> { - TravelCertificateTravellersInputUiState.UrlFetched( - screenContentValue.travelCertificateUrl, - ) - } } } } @@ -187,8 +187,6 @@ private sealed interface TravelersInputScreenContent { data object Failure : TravelersInputScreenContent - data class UrlFetched(val travelCertificateUrl: TravelCertificateUrl) : TravelersInputScreenContent - data class Success( val coInsuredHasMissingInfo: Boolean, val memberFullName: String, @@ -201,8 +199,6 @@ internal sealed interface TravelCertificateTravellersInputUiState { data object Failure : TravelCertificateTravellersInputUiState - data class UrlFetched(val travelCertificateUrl: TravelCertificateUrl) : TravelCertificateTravellersInputUiState - data class Success( val coInsuredHasMissingInfo: Boolean, val coInsuredList: List, From eca2654b00cf5046419017ef7ebc5a4102cfb9b6 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Sun, 7 Jun 2026 23:23:32 +0300 Subject: [PATCH 02/12] Allow feature-x-navigation modules to be depended on cross-feature To navigate directly to another feature's entry point, a presenter must be able to name that feature's nav key. Today every key is locked inside its owning feature module, and the feature-can't-depend-on-feature rule forbids reaching it, which forces a flag-dance routed through the app module. This enables a `feature-x-navigation` sister module that hosts only the keys of feature-x meant to be reached from other features: - HedvigGradlePlugin exempts `-navigation` modules from the feature dependency check, so any feature can depend on them while the owning feature keeps its entries/screens and internal-only keys private. - NavKeySerializerProcessor no longer derives the generated provider's name from the package alone (a feature and its `-navigation` sister legitimately share a package, e.g. `.navigation`, which would collide). The name now folds in a stable hash of the module's key FQNs, which are globally unique by construction since a key class is declared in exactly one module. This keeps the processor self-contained with no Gradle module name to inject. --- .../processor/NavKeySerializerProcessor.kt | 25 ++++++++++++++++--- .../src/main/kotlin/HedvigGradlePlugin.kt | 12 ++++++++- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/app/navigation/navigation-keys-processor/src/main/kotlin/com/hedvig/android/navigation/keys/processor/NavKeySerializerProcessor.kt b/app/navigation/navigation-keys-processor/src/main/kotlin/com/hedvig/android/navigation/keys/processor/NavKeySerializerProcessor.kt index 8bdf1e7c2c..3f99ff784e 100644 --- a/app/navigation/navigation-keys-processor/src/main/kotlin/com/hedvig/android/navigation/keys/processor/NavKeySerializerProcessor.kt +++ b/app/navigation/navigation-keys-processor/src/main/kotlin/com/hedvig/android/navigation/keys/processor/NavKeySerializerProcessor.kt @@ -90,10 +90,15 @@ class NavKeySerializerProcessor( val packageName = keys.first().packageName.asString() // The generated interface is merged into the app-wide Metro graph alongside every other module's - // generated provider. A shared method name would make the graph inherit conflicting same-signature - // default methods (a diamond), so derive a per-package token to keep the interface and provide - // method names unique across modules. - val uniqueToken = packageName.toUniqueToken() + // generated provider, so its name and provide-method name must be unique across modules or the + // graph inherits a duplicate type / same-signature method diamond. The package alone is not enough: + // a feature and its `feature-x-navigation` sister legitimately share a package (e.g. `.navigation`). + // A nav-key class is declared in exactly one module and KSP only processes the current compilation's + // declarations, so this module's set of key FQNs is globally unique by construction — fold it into + // the token. This keeps the processor self-contained (no Gradle module name to inject) and impossible + // to silently de-duplicate wrong. + val keyFqns = keys.mapNotNull { it.qualifiedName?.asString() } + val uniqueToken = "${packageName.toUniqueToken()}_${keyFqns.joinToString("\n").stableHash()}" val provideFunction = FunSpec.builder("provide${uniqueToken}NavKeySerializersModule") .addAnnotation(provides) @@ -138,6 +143,18 @@ private fun String.toUniqueToken(): String = split('.') .dropWhile { it == "com" || it == "hedvig" } .joinToString("") { segment -> segment.replaceFirstChar { it.uppercaseChar() } } +// Deterministic, dependency-free FNV-1a 32-bit hash rendered as hex. Stable across machines and JVM +// versions (unlike a salted hash), and only changes when the module's key set changes — which already +// regenerates this file anyway. +private fun String.stableHash(): String { + var hash = -2128831035 // FNV-1a 32-bit offset basis + for (char in this) { + hash = hash xor char.code + hash *= 16777619 // FNV-1a 32-bit prime + } + return Integer.toHexString(hash) +} + class NavKeySerializerProcessorProvider : SymbolProcessorProvider { override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor = NavKeySerializerProcessor(environment) diff --git a/build-logic/convention/src/main/kotlin/HedvigGradlePlugin.kt b/build-logic/convention/src/main/kotlin/HedvigGradlePlugin.kt index dbbe7fdea0..4968eedaf4 100644 --- a/build-logic/convention/src/main/kotlin/HedvigGradlePlugin.kt +++ b/build-logic/convention/src/main/kotlin/HedvigGradlePlugin.kt @@ -87,6 +87,13 @@ private fun Project.configureFeatureModuleGuidelines() { fun String.isFeatureModule(): Boolean { return startsWith("feature-") && !startsWith("feature-flags") } + // A `feature-x-navigation` module hosts only the nav keys of `feature-x` that are meant to be + // reached from other feature modules. It is intentionally depend-able cross-feature so that a + // presenter can navigate directly to another feature's entry point, while the owning feature + // keeps its entries/screens and internal-only keys private. + fun String.isFeatureNavigationModule(): Boolean { + return isFeatureModule() && endsWith("-navigation") + } val thisModuleName = this.name if (!thisModuleName.isFeatureModule()) return @@ -95,13 +102,16 @@ private fun Project.configureFeatureModuleGuidelines() { eachDependency { if (requested.group != "hedvigandroid") return@eachDependency // Only check for our own modules if (requested.name == thisModuleName) return@eachDependency // Only check deps to other modules + // A `-navigation` module exists precisely to be depended on across features. + if (requested.name.isFeatureNavigationModule()) return@eachDependency val requestedModuleIsAFeatureModule = requested.name.isFeatureModule() require(!requestedModuleIsAFeatureModule) { "Hedvig build error on a module marked as featureModule() in HGP." + "\nYou are trying to depend on another feature module from a feature module." + "\nThis is not allowed as it breaks our ability to properly share code between modules." + "\nIn particular, $thisModuleName is trying to depend on ${requested.name}." + - "\nIf you need to share code between feature modules, consider moving the shared code to a library module." + "\nIf you need to share code between feature modules, consider moving the shared code to a library module." + + "\nIf you only need that feature's navigation keys, depend on its `feature-x-navigation` module instead." } } } From a94ac84622da3a48aceb40c12480766db958f00f Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Mon, 8 Jun 2026 10:05:56 +0300 Subject: [PATCH 03/12] Navigate cross-feature via concrete keys from -navigation modules Replace the field-setting navigation dance (presenter sets a UiState flag -> UI LaunchedEffect -> injected lambda -> central when mapping intent to key) with presenters that add concrete nav keys to the Backstack directly. Introduce feature-x-navigation sister modules hosting only the keys meant to be reached cross-feature, which the build plugin exempts from the feature->feature dependency ban. Rewrite HelpCenterPresenter to map every quick-link destination straight to a key, dropping destinationToNavigate and the ClearNavigation event, and remove the central when lambda in the app's HedvigEntryProvider. --- app/app/build.gradle.kts | 6 ++ .../app/navigation/HedvigEntryProvider.kt | 61 -------------- .../build.gradle.kts | 20 +++++ .../StartTierFlowChooseInsuranceKey.kt | 7 ++ .../feature-choose-tier/build.gradle.kts | 1 + .../navigation/ChooseTierNavDestination.kt | 6 -- .../build.gradle.kts | 20 +++++ .../connect/payment/trustly/ui/TrustlyKey.kt | 7 ++ .../build.gradle.kts | 1 + .../payment/trustly/ui/TrustlyDestination.kt | 5 -- .../build.gradle.kts | 21 +++++ .../navigation/EditCoInsuredKeys.kt | 25 ++++++ .../feature-edit-coinsured/build.gradle.kts | 1 + .../navigation/EditCoInsuredDestinations.kt | 19 ----- .../feature-help-center/build.gradle.kts | 7 ++ .../feature/help/center/HelpCenterEntries.kt | 5 -- .../help/center/HelpCenterPresenter.kt | 81 +++++++++++++++---- .../center/home/HelpCenterHomeDestination.kt | 8 -- .../build.gradle.kts | 20 +++++ .../movingflow/SelectContractForMovingKey.kt | 7 ++ .../feature-movingflow/build.gradle.kts | 1 + .../feature/movingflow/MovingFlowEntries.kt | 3 - .../build.gradle.kts | 20 +++++ .../navigation/TerminateInsuranceKey.kt | 11 +++ .../build.gradle.kts | 1 + .../TerminateInsuranceDestination.kt | 7 -- .../build.gradle.kts | 20 +++++ .../navigation/TravelCertificateKey.kt | 8 ++ .../build.gradle.kts | 1 + .../TravelCertificateDestination.kt | 4 - 30 files changed, 272 insertions(+), 132 deletions(-) create mode 100644 app/feature/feature-choose-tier-navigation/build.gradle.kts create mode 100644 app/feature/feature-choose-tier-navigation/src/commonMain/kotlin/com/hedvig/android/feature/change/tier/navigation/StartTierFlowChooseInsuranceKey.kt create mode 100644 app/feature/feature-connect-payment-trustly-navigation/build.gradle.kts create mode 100644 app/feature/feature-connect-payment-trustly-navigation/src/commonMain/kotlin/com/hedvig/android/feature/connect/payment/trustly/ui/TrustlyKey.kt create mode 100644 app/feature/feature-edit-coinsured-navigation/build.gradle.kts create mode 100644 app/feature/feature-edit-coinsured-navigation/src/commonMain/kotlin/com/hedvig/android/feature/editcoinsured/navigation/EditCoInsuredKeys.kt create mode 100644 app/feature/feature-movingflow-navigation/build.gradle.kts create mode 100644 app/feature/feature-movingflow-navigation/src/commonMain/kotlin/com/hedvig/android/feature/movingflow/SelectContractForMovingKey.kt create mode 100644 app/feature/feature-terminate-insurance-navigation/build.gradle.kts create mode 100644 app/feature/feature-terminate-insurance-navigation/src/commonMain/kotlin/com/hedvig/android/feature/terminateinsurance/navigation/TerminateInsuranceKey.kt create mode 100644 app/feature/feature-travel-certificate-navigation/build.gradle.kts create mode 100644 app/feature/feature-travel-certificate-navigation/src/commonMain/kotlin/com/hedvig/android/feature/travelcertificate/navigation/TravelCertificateKey.kt diff --git a/app/app/build.gradle.kts b/app/app/build.gradle.kts index 0d4a95a042..48210a87a6 100644 --- a/app/app/build.gradle.kts +++ b/app/app/build.gradle.kts @@ -189,13 +189,16 @@ dependencies { implementation(projects.featureChat) implementation(projects.featureChipId) implementation(projects.featureChooseTier) + implementation(projects.featureChooseTierNavigation) implementation(projects.featureClaimChat) implementation(projects.featureClaimDetails) implementation(projects.featureClaimHistory) implementation(projects.featureConnectPaymentTrustly) + implementation(projects.featureConnectPaymentTrustlyNavigation) implementation(projects.featureCrossSellSheet) implementation(projects.featureDeleteAccount) implementation(projects.featureEditCoinsured) + implementation(projects.featureEditCoinsuredNavigation) implementation(projects.featureFlags) implementation(projects.featureForever) implementation(projects.featureHelpCenter) @@ -205,13 +208,16 @@ dependencies { implementation(projects.featureInsurances) implementation(projects.featureLogin) implementation(projects.featureMovingflow) + implementation(projects.featureMovingflowNavigation) implementation(projects.featureRemoveAddons) implementation(projects.featurePayoutAccount) implementation(projects.featurePayments) implementation(projects.featureProfile) implementation(projects.featureTerminateInsurance) + implementation(projects.featureTerminateInsuranceNavigation) implementation(projects.featureTravelCertificate) + implementation(projects.featureTravelCertificateNavigation) implementation(projects.foreverUi) implementation(projects.initializable) implementation(projects.languageCore) diff --git a/app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigEntryProvider.kt b/app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigEntryProvider.kt index a7fe2f4851..dd330b8b82 100644 --- a/app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigEntryProvider.kt +++ b/app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigEntryProvider.kt @@ -15,7 +15,6 @@ import com.hedvig.android.feature.addon.purchase.navigation.AddonPurchaseKey import com.hedvig.android.feature.addon.purchase.navigation.addonPurchaseEntries import com.hedvig.android.feature.change.tier.navigation.ChooseTierKey import com.hedvig.android.feature.change.tier.navigation.InsuranceCustomizationParameters -import com.hedvig.android.feature.change.tier.navigation.StartTierFlowChooseInsuranceKey import com.hedvig.android.feature.change.tier.navigation.StartTierFlowKey import com.hedvig.android.feature.change.tier.navigation.changeTierEntries import com.hedvig.android.feature.chat.navigation.ChatKey @@ -36,17 +35,6 @@ import com.hedvig.android.feature.editcoinsured.navigation.CoInsuredAddOrRemoveK import com.hedvig.android.feature.editcoinsured.navigation.EditCoInsuredTriageKey import com.hedvig.android.feature.editcoinsured.navigation.editCoInsuredEntries import com.hedvig.android.feature.forever.navigation.foreverEntries -import com.hedvig.android.feature.help.center.data.QuickLinkDestination.OuterDestination.ChooseInsuranceForEditCoInsured -import com.hedvig.android.feature.help.center.data.QuickLinkDestination.OuterDestination.ChooseInsuranceForEditCoOwners -import com.hedvig.android.feature.help.center.data.QuickLinkDestination.OuterDestination.QuickLinkChangeAddress -import com.hedvig.android.feature.help.center.data.QuickLinkDestination.OuterDestination.QuickLinkChangeTier -import com.hedvig.android.feature.help.center.data.QuickLinkDestination.OuterDestination.QuickLinkCoInsuredAddInfo -import com.hedvig.android.feature.help.center.data.QuickLinkDestination.OuterDestination.QuickLinkCoInsuredAddOrRemove -import com.hedvig.android.feature.help.center.data.QuickLinkDestination.OuterDestination.QuickLinkCoOwnerAddInfo -import com.hedvig.android.feature.help.center.data.QuickLinkDestination.OuterDestination.QuickLinkCoOwnerAddOrRemove -import com.hedvig.android.feature.help.center.data.QuickLinkDestination.OuterDestination.QuickLinkConnectPayment -import com.hedvig.android.feature.help.center.data.QuickLinkDestination.OuterDestination.QuickLinkTermination -import com.hedvig.android.feature.help.center.data.QuickLinkDestination.OuterDestination.QuickLinkTravelCertificate import com.hedvig.android.feature.help.center.helpCenterEntries import com.hedvig.android.feature.help.center.navigation.HelpCenterKey import com.hedvig.android.feature.home.home.navigation.homeEntries @@ -568,55 +556,6 @@ private fun EntryProviderScope.addSharedFlowEntries( helpCenterEntries( backstack = backstack, onNavigateUp = backstack::navigateUp, - onNavigateToQuickLink = onNavigateToQuickLink@{ quickLinkDestination -> - val destination: HedvigNavKey = when (quickLinkDestination) { - QuickLinkChangeAddress -> { - navigateToMovingFlow() - return@onNavigateToQuickLink - } - - is QuickLinkCoInsuredAddInfo -> { - CoInsuredAddInfoKey(quickLinkDestination.contractId, CoInsuredFlowType.CoInsured) - } - - is QuickLinkCoInsuredAddOrRemove -> { - CoInsuredAddOrRemoveKey(quickLinkDestination.contractId, CoInsuredFlowType.CoInsured) - } - - is QuickLinkCoOwnerAddInfo -> { - CoInsuredAddInfoKey(quickLinkDestination.contractId, CoInsuredFlowType.CoOwners) - } - - is QuickLinkCoOwnerAddOrRemove -> { - CoInsuredAddOrRemoveKey(quickLinkDestination.contractId, CoInsuredFlowType.CoOwners) - } - - QuickLinkConnectPayment -> { - TrustlyKey - } - - QuickLinkTermination -> { - TerminateInsuranceKey(null) - } - - QuickLinkTravelCertificate -> { - TravelCertificateKey - } - - QuickLinkChangeTier -> { - StartTierFlowChooseInsuranceKey - } - - ChooseInsuranceForEditCoInsured -> { - EditCoInsuredTriageKey() - } - - ChooseInsuranceForEditCoOwners -> { - EditCoInsuredTriageKey(type = CoInsuredFlowType.CoOwners) - } - } - backstack.add(destination) - }, onNavigateToNewConversation = navigateToNewConversation, onNavigateToInbox = navigateToInbox, openUrl = openUrl, diff --git a/app/feature/feature-choose-tier-navigation/build.gradle.kts b/app/feature/feature-choose-tier-navigation/build.gradle.kts new file mode 100644 index 0000000000..288520f5a2 --- /dev/null +++ b/app/feature/feature-choose-tier-navigation/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + id("hedvig.multiplatform.library") + id("hedvig.multiplatform.library.android") + id("hedvig.gradle.plugin") +} + +hedvig { + serialization() + navKeys() +} + +kotlin { + sourceSets { + commonMain.dependencies { + implementation(libs.kotlinx.serialization.core) + implementation(projects.coreCommonPublic) + implementation(projects.navigationCommon) + } + } +} diff --git a/app/feature/feature-choose-tier-navigation/src/commonMain/kotlin/com/hedvig/android/feature/change/tier/navigation/StartTierFlowChooseInsuranceKey.kt b/app/feature/feature-choose-tier-navigation/src/commonMain/kotlin/com/hedvig/android/feature/change/tier/navigation/StartTierFlowChooseInsuranceKey.kt new file mode 100644 index 0000000000..4e63abe773 --- /dev/null +++ b/app/feature/feature-choose-tier-navigation/src/commonMain/kotlin/com/hedvig/android/feature/change/tier/navigation/StartTierFlowChooseInsuranceKey.kt @@ -0,0 +1,7 @@ +package com.hedvig.android.feature.change.tier.navigation + +import com.hedvig.android.navigation.common.HedvigNavKey +import kotlinx.serialization.Serializable + +@Serializable +data object StartTierFlowChooseInsuranceKey : HedvigNavKey diff --git a/app/feature/feature-choose-tier/build.gradle.kts b/app/feature/feature-choose-tier/build.gradle.kts index 83f431eaac..e5eba9c1f8 100644 --- a/app/feature/feature-choose-tier/build.gradle.kts +++ b/app/feature/feature-choose-tier/build.gradle.kts @@ -36,6 +36,7 @@ dependencies { implementation(projects.designSystemHedvig) implementation(projects.featureFlags) implementation(projects.languageCore) + implementation(projects.featureChooseTierNavigation) implementation(projects.moleculePublic) implementation(projects.navigationCommon) implementation(projects.navigationCompose) diff --git a/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/navigation/ChooseTierNavDestination.kt b/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/navigation/ChooseTierNavDestination.kt index 1356e04983..0a144f2a45 100644 --- a/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/navigation/ChooseTierNavDestination.kt +++ b/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/navigation/ChooseTierNavDestination.kt @@ -17,12 +17,6 @@ data class StartTierFlowKey( val insuranceId: String, ) : HedvigNavKey -/** - * The start of the flow, where we have can choose insurance to change its tier - */ -@Serializable -data object StartTierFlowChooseInsuranceKey : HedvigNavKey - @Serializable data class ChooseTierKey( /** diff --git a/app/feature/feature-connect-payment-trustly-navigation/build.gradle.kts b/app/feature/feature-connect-payment-trustly-navigation/build.gradle.kts new file mode 100644 index 0000000000..288520f5a2 --- /dev/null +++ b/app/feature/feature-connect-payment-trustly-navigation/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + id("hedvig.multiplatform.library") + id("hedvig.multiplatform.library.android") + id("hedvig.gradle.plugin") +} + +hedvig { + serialization() + navKeys() +} + +kotlin { + sourceSets { + commonMain.dependencies { + implementation(libs.kotlinx.serialization.core) + implementation(projects.coreCommonPublic) + implementation(projects.navigationCommon) + } + } +} diff --git a/app/feature/feature-connect-payment-trustly-navigation/src/commonMain/kotlin/com/hedvig/android/feature/connect/payment/trustly/ui/TrustlyKey.kt b/app/feature/feature-connect-payment-trustly-navigation/src/commonMain/kotlin/com/hedvig/android/feature/connect/payment/trustly/ui/TrustlyKey.kt new file mode 100644 index 0000000000..2292d02bef --- /dev/null +++ b/app/feature/feature-connect-payment-trustly-navigation/src/commonMain/kotlin/com/hedvig/android/feature/connect/payment/trustly/ui/TrustlyKey.kt @@ -0,0 +1,7 @@ +package com.hedvig.android.feature.connect.payment.trustly.ui + +import com.hedvig.android.navigation.common.HedvigNavKey +import kotlinx.serialization.Serializable + +@Serializable +data object TrustlyKey : HedvigNavKey diff --git a/app/feature/feature-connect-payment-trustly/build.gradle.kts b/app/feature/feature-connect-payment-trustly/build.gradle.kts index 7230d0df9a..d950d024a3 100644 --- a/app/feature/feature-connect-payment-trustly/build.gradle.kts +++ b/app/feature/feature-connect-payment-trustly/build.gradle.kts @@ -39,6 +39,7 @@ dependencies { implementation(projects.coreCommonPublic) implementation(projects.coreResources) implementation(projects.designSystemHedvig) + implementation(projects.featureConnectPaymentTrustlyNavigation) implementation(projects.moleculePublic) implementation(projects.navigationCommon) implementation(projects.navigationCompose) diff --git a/app/feature/feature-connect-payment-trustly/src/main/kotlin/com/hedvig/android/feature/connect/payment/trustly/ui/TrustlyDestination.kt b/app/feature/feature-connect-payment-trustly/src/main/kotlin/com/hedvig/android/feature/connect/payment/trustly/ui/TrustlyDestination.kt index f04cc59d65..6ae54cb5bc 100644 --- a/app/feature/feature-connect-payment-trustly/src/main/kotlin/com/hedvig/android/feature/connect/payment/trustly/ui/TrustlyDestination.kt +++ b/app/feature/feature-connect-payment-trustly/src/main/kotlin/com/hedvig/android/feature/connect/payment/trustly/ui/TrustlyDestination.kt @@ -46,19 +46,14 @@ import com.hedvig.android.feature.connect.payment.trustly.sdk.TrustlyWebView import com.hedvig.android.feature.connect.payment.trustly.sdk.TrustlyWebViewClient import com.hedvig.android.logger.logcat import com.hedvig.android.molecule.public.MoleculeViewModel -import com.hedvig.android.navigation.common.HedvigNavKey import hedvig.resources.Res import hedvig.resources.general_done_button import hedvig.resources.pay_in_confirmation_direct_debit_headline import hedvig.resources.pay_in_error_body import hedvig.resources.pay_in_explainer_direct_debit_headline import hedvig.resources.something_went_wrong -import kotlinx.serialization.Serializable import org.jetbrains.compose.resources.stringResource -@Serializable -data object TrustlyKey : HedvigNavKey - @Composable internal fun TrustlyDestination( viewModel: MoleculeViewModel, diff --git a/app/feature/feature-edit-coinsured-navigation/build.gradle.kts b/app/feature/feature-edit-coinsured-navigation/build.gradle.kts new file mode 100644 index 0000000000..96aa07a337 --- /dev/null +++ b/app/feature/feature-edit-coinsured-navigation/build.gradle.kts @@ -0,0 +1,21 @@ +plugins { + id("hedvig.multiplatform.library") + id("hedvig.multiplatform.library.android") + id("hedvig.gradle.plugin") +} + +hedvig { + serialization() + navKeys() +} + +kotlin { + sourceSets { + commonMain.dependencies { + implementation(libs.kotlinx.serialization.core) + implementation(projects.coreCommonPublic) + implementation(projects.dataCoinsured) + implementation(projects.navigationCommon) + } + } +} diff --git a/app/feature/feature-edit-coinsured-navigation/src/commonMain/kotlin/com/hedvig/android/feature/editcoinsured/navigation/EditCoInsuredKeys.kt b/app/feature/feature-edit-coinsured-navigation/src/commonMain/kotlin/com/hedvig/android/feature/editcoinsured/navigation/EditCoInsuredKeys.kt new file mode 100644 index 0000000000..a41e88f2bb --- /dev/null +++ b/app/feature/feature-edit-coinsured-navigation/src/commonMain/kotlin/com/hedvig/android/feature/editcoinsured/navigation/EditCoInsuredKeys.kt @@ -0,0 +1,25 @@ +package com.hedvig.android.feature.editcoinsured.navigation + +import com.hedvig.android.data.coinsured.CoInsuredFlowType +import com.hedvig.android.navigation.common.HedvigNavKey +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class CoInsuredAddInfoKey( + val contractId: String, + val type: CoInsuredFlowType, +) : HedvigNavKey + +@Serializable +data class CoInsuredAddOrRemoveKey( + val contractId: String, + val type: CoInsuredFlowType, +) : HedvigNavKey + +@Serializable +data class EditCoInsuredTriageKey( + @SerialName("contractId") + val contractId: String? = null, + val type: CoInsuredFlowType = CoInsuredFlowType.CoInsured, +) : HedvigNavKey diff --git a/app/feature/feature-edit-coinsured/build.gradle.kts b/app/feature/feature-edit-coinsured/build.gradle.kts index ef27b87f0b..d536ad06c5 100644 --- a/app/feature/feature-edit-coinsured/build.gradle.kts +++ b/app/feature/feature-edit-coinsured/build.gradle.kts @@ -37,6 +37,7 @@ dependencies { implementation(projects.coreUiData) implementation(projects.designSystemApi) implementation(projects.designSystemHedvig) + implementation(projects.featureEditCoinsuredNavigation) implementation(projects.moleculePublic) implementation(projects.navigationCommon) implementation(projects.navigationCompose) diff --git a/app/feature/feature-edit-coinsured/src/main/kotlin/com/hedvig/android/feature/editcoinsured/navigation/EditCoInsuredDestinations.kt b/app/feature/feature-edit-coinsured/src/main/kotlin/com/hedvig/android/feature/editcoinsured/navigation/EditCoInsuredDestinations.kt index a1f0cc8375..beb0e2c5de 100644 --- a/app/feature/feature-edit-coinsured/src/main/kotlin/com/hedvig/android/feature/editcoinsured/navigation/EditCoInsuredDestinations.kt +++ b/app/feature/feature-edit-coinsured/src/main/kotlin/com/hedvig/android/feature/editcoinsured/navigation/EditCoInsuredDestinations.kt @@ -9,25 +9,6 @@ import kotlinx.datetime.LocalDate import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -@Serializable -data class CoInsuredAddInfoKey( - val contractId: String, - val type: CoInsuredFlowType, -) : HedvigNavKey - -@Serializable -data class CoInsuredAddOrRemoveKey( - val contractId: String, - val type: CoInsuredFlowType, -) : HedvigNavKey - -@Serializable -data class EditCoInsuredTriageKey( - @SerialName("contractId") - val contractId: String? = null, - val type: CoInsuredFlowType = CoInsuredFlowType.CoInsured, -) : HedvigNavKey - @Serializable internal data class EditCoOwnersTriageDeepLinkKey( @SerialName("contractId") diff --git a/app/feature/feature-help-center/build.gradle.kts b/app/feature/feature-help-center/build.gradle.kts index 9548fd7dc2..1f1e862eff 100644 --- a/app/feature/feature-help-center/build.gradle.kts +++ b/app/feature/feature-help-center/build.gradle.kts @@ -46,11 +46,18 @@ kotlin { implementation(projects.coreBuildConstants) implementation(projects.coreCommonPublic) implementation(projects.coreResources) + implementation(projects.dataCoinsured) implementation(projects.dataContract) implementation(projects.dataConversations) implementation(projects.dataTermination) implementation(projects.designSystemHedvig) + implementation(projects.featureChooseTierNavigation) + implementation(projects.featureConnectPaymentTrustlyNavigation) + implementation(projects.featureEditCoinsuredNavigation) implementation(projects.featureFlags) + implementation(projects.featureMovingflowNavigation) + implementation(projects.featureTerminateInsuranceNavigation) + implementation(projects.featureTravelCertificateNavigation) implementation(projects.moleculePublic) implementation(projects.navigationCommon) implementation(projects.navigationCompose) diff --git a/app/feature/feature-help-center/src/commonMain/kotlin/com/hedvig/android/feature/help/center/HelpCenterEntries.kt b/app/feature/feature-help-center/src/commonMain/kotlin/com/hedvig/android/feature/help/center/HelpCenterEntries.kt index 0e7708e7a7..36f3b63185 100644 --- a/app/feature/feature-help-center/src/commonMain/kotlin/com/hedvig/android/feature/help/center/HelpCenterEntries.kt +++ b/app/feature/feature-help-center/src/commonMain/kotlin/com/hedvig/android/feature/help/center/HelpCenterEntries.kt @@ -6,7 +6,6 @@ import coil3.ImageLoader import com.hedvig.android.compose.ui.dropUnlessResumed import com.hedvig.android.feature.help.center.commonclaim.FirstVetDestination import com.hedvig.android.feature.help.center.commonclaim.emergency.EmergencyDestination -import com.hedvig.android.feature.help.center.data.QuickLinkDestination import com.hedvig.android.feature.help.center.home.HelpCenterHomeDestination import com.hedvig.android.feature.help.center.navigation.EmergencyKey import com.hedvig.android.feature.help.center.navigation.FirstVetKey @@ -33,7 +32,6 @@ import dev.zacsweers.metrox.viewmodel.metroViewModel fun EntryProviderScope.helpCenterEntries( backstack: Backstack, onNavigateUp: () -> Unit, - onNavigateToQuickLink: (QuickLinkDestination.OuterDestination) -> Unit, onNavigateToInbox: () -> Unit, onNavigateToNewConversation: () -> Unit, openUrl: (String) -> Unit, @@ -50,9 +48,6 @@ fun EntryProviderScope.helpCenterEntries( onNavigateToQuestion = dropUnlessResumed { question -> navigateToQuestion(question, backstack) }, - onNavigateToQuickLink = dropUnlessResumed { destination -> - onNavigateToQuickLink(destination) - }, onNavigateToInbox = dropUnlessResumed { onNavigateToInbox() }, diff --git a/app/feature/feature-help-center/src/commonMain/kotlin/com/hedvig/android/feature/help/center/HelpCenterPresenter.kt b/app/feature/feature-help-center/src/commonMain/kotlin/com/hedvig/android/feature/help/center/HelpCenterPresenter.kt index f6cde0670b..90283e4bea 100644 --- a/app/feature/feature-help-center/src/commonMain/kotlin/com/hedvig/android/feature/help/center/HelpCenterPresenter.kt +++ b/app/feature/feature-help-center/src/commonMain/kotlin/com/hedvig/android/feature/help/center/HelpCenterPresenter.kt @@ -11,8 +11,13 @@ import androidx.compose.runtime.setValue import arrow.core.NonEmptyList import arrow.core.merge import arrow.core.toNonEmptyListOrNull +import com.hedvig.android.data.coinsured.CoInsuredFlowType import com.hedvig.android.data.conversations.HasAnyActiveConversationUseCase -import com.hedvig.android.feature.help.center.HelpCenterEvent.ClearNavigation +import com.hedvig.android.feature.change.tier.navigation.StartTierFlowChooseInsuranceKey +import com.hedvig.android.feature.connect.payment.trustly.ui.TrustlyKey +import com.hedvig.android.feature.editcoinsured.navigation.CoInsuredAddInfoKey +import com.hedvig.android.feature.editcoinsured.navigation.CoInsuredAddOrRemoveKey +import com.hedvig.android.feature.editcoinsured.navigation.EditCoInsuredTriageKey import com.hedvig.android.feature.help.center.HelpCenterEvent.ClearSearchQuery import com.hedvig.android.feature.help.center.HelpCenterEvent.NavigateToQuickAction import com.hedvig.android.feature.help.center.HelpCenterEvent.OnDismissQuickActionDialog @@ -28,11 +33,26 @@ import com.hedvig.android.feature.help.center.data.GetPuppyGuideUseCase import com.hedvig.android.feature.help.center.data.GetQuickLinksUseCase import com.hedvig.android.feature.help.center.data.InnerHelpCenterDestination import com.hedvig.android.feature.help.center.data.QuickLinkDestination +import com.hedvig.android.feature.help.center.data.QuickLinkDestination.OuterDestination.ChooseInsuranceForEditCoInsured +import com.hedvig.android.feature.help.center.data.QuickLinkDestination.OuterDestination.ChooseInsuranceForEditCoOwners +import com.hedvig.android.feature.help.center.data.QuickLinkDestination.OuterDestination.QuickLinkChangeAddress +import com.hedvig.android.feature.help.center.data.QuickLinkDestination.OuterDestination.QuickLinkChangeTier +import com.hedvig.android.feature.help.center.data.QuickLinkDestination.OuterDestination.QuickLinkCoInsuredAddInfo +import com.hedvig.android.feature.help.center.data.QuickLinkDestination.OuterDestination.QuickLinkCoInsuredAddOrRemove +import com.hedvig.android.feature.help.center.data.QuickLinkDestination.OuterDestination.QuickLinkCoOwnerAddInfo +import com.hedvig.android.feature.help.center.data.QuickLinkDestination.OuterDestination.QuickLinkCoOwnerAddOrRemove +import com.hedvig.android.feature.help.center.data.QuickLinkDestination.OuterDestination.QuickLinkConnectPayment +import com.hedvig.android.feature.help.center.data.QuickLinkDestination.OuterDestination.QuickLinkTermination +import com.hedvig.android.feature.help.center.data.QuickLinkDestination.OuterDestination.QuickLinkTravelCertificate import com.hedvig.android.feature.help.center.model.QuickAction import com.hedvig.android.feature.help.center.navigation.EmergencyKey import com.hedvig.android.feature.help.center.navigation.FirstVetKey +import com.hedvig.android.feature.movingflow.SelectContractForMovingKey +import com.hedvig.android.feature.terminateinsurance.navigation.TerminateInsuranceKey +import com.hedvig.android.feature.travelcertificate.navigation.TravelCertificateKey import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.navigation.common.HedvigNavKey import com.hedvig.android.navigation.compose.Backstack import com.hedvig.android.navigation.compose.add import kotlinx.coroutines.flow.collect @@ -54,8 +74,6 @@ internal sealed interface HelpCenterEvent { data class NavigateToQuickAction(val destination: QuickLinkDestination) : HelpCenterEvent - data object ClearNavigation : HelpCenterEvent - data object ReloadFAQAndQuickLinks : HelpCenterEvent } @@ -66,7 +84,6 @@ internal data class HelpCenterUiState( val selectedQuickAction: QuickAction?, val search: Search?, val showNavigateToInboxButton: Boolean, - val destinationToNavigate: QuickLinkDestination.OuterDestination? = null, val puppyGuide: PuppyGuidePresentation?, ) { data class QuickLink(val quickAction: QuickAction) @@ -153,26 +170,62 @@ internal class HelpCenterPresenter( } } - ClearNavigation -> { - selectedQuickAction = null - currentState = currentState.copy(destinationToNavigate = null) - } - is NavigateToQuickAction -> { selectedQuickAction = null - when (val destination = event.destination) { + val key: HedvigNavKey = when (val destination = event.destination) { is InnerHelpCenterDestination.FirstVet -> { - backstack.add(FirstVetKey(destination.sections)) + FirstVetKey(destination.sections) } is InnerHelpCenterDestination.QuickLinkSickAbroad -> { - backstack.add(EmergencyKey(destination.deflectData)) + EmergencyKey(destination.deflectData) + } + + QuickLinkChangeAddress -> { + SelectContractForMovingKey + } + + is QuickLinkCoInsuredAddInfo -> { + CoInsuredAddInfoKey(destination.contractId, CoInsuredFlowType.CoInsured) + } + + is QuickLinkCoInsuredAddOrRemove -> { + CoInsuredAddOrRemoveKey(destination.contractId, CoInsuredFlowType.CoInsured) + } + + is QuickLinkCoOwnerAddInfo -> { + CoInsuredAddInfoKey(destination.contractId, CoInsuredFlowType.CoOwners) + } + + is QuickLinkCoOwnerAddOrRemove -> { + CoInsuredAddOrRemoveKey(destination.contractId, CoInsuredFlowType.CoOwners) + } + + QuickLinkConnectPayment -> { + TrustlyKey + } + + QuickLinkTermination -> { + TerminateInsuranceKey(null) + } + + QuickLinkTravelCertificate -> { + TravelCertificateKey + } + + QuickLinkChangeTier -> { + StartTierFlowChooseInsuranceKey + } + + ChooseInsuranceForEditCoInsured -> { + EditCoInsuredTriageKey() } - is QuickLinkDestination.OuterDestination -> { - currentState = currentState.copy(destinationToNavigate = destination) + ChooseInsuranceForEditCoOwners -> { + EditCoInsuredTriageKey(type = CoInsuredFlowType.CoOwners) } } + backstack.add(key) } HelpCenterEvent.ReloadFAQAndQuickLinks -> { diff --git a/app/feature/feature-help-center/src/commonMain/kotlin/com/hedvig/android/feature/help/center/home/HelpCenterHomeDestination.kt b/app/feature/feature-help-center/src/commonMain/kotlin/com/hedvig/android/feature/help/center/home/HelpCenterHomeDestination.kt index 0bd5504f13..0bd5778c3a 100644 --- a/app/feature/feature-help-center/src/commonMain/kotlin/com/hedvig/android/feature/help/center/home/HelpCenterHomeDestination.kt +++ b/app/feature/feature-help-center/src/commonMain/kotlin/com/hedvig/android/feature/help/center/home/HelpCenterHomeDestination.kt @@ -134,20 +134,12 @@ internal fun HelpCenterHomeDestination( viewModel: HelpCenterViewModel, onNavigateToTopic: (topicId: String) -> Unit, onNavigateToQuestion: (questionId: String) -> Unit, - onNavigateToQuickLink: (QuickLinkDestination.OuterDestination) -> Unit, onNavigateUp: () -> Unit, onNavigateToInbox: () -> Unit, onNavigateToNewConversation: () -> Unit, onNavigateToPuppyGuide: () -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - LaunchedEffect(uiState.destinationToNavigate) { - val destination = uiState.destinationToNavigate - if (destination != null && uiState.selectedQuickAction == null) { - viewModel.emit(HelpCenterEvent.ClearNavigation) - onNavigateToQuickLink(destination) - } - } HelpCenterHomeScreen( topics = uiState.topics, questions = uiState.questions, diff --git a/app/feature/feature-movingflow-navigation/build.gradle.kts b/app/feature/feature-movingflow-navigation/build.gradle.kts new file mode 100644 index 0000000000..288520f5a2 --- /dev/null +++ b/app/feature/feature-movingflow-navigation/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + id("hedvig.multiplatform.library") + id("hedvig.multiplatform.library.android") + id("hedvig.gradle.plugin") +} + +hedvig { + serialization() + navKeys() +} + +kotlin { + sourceSets { + commonMain.dependencies { + implementation(libs.kotlinx.serialization.core) + implementation(projects.coreCommonPublic) + implementation(projects.navigationCommon) + } + } +} diff --git a/app/feature/feature-movingflow-navigation/src/commonMain/kotlin/com/hedvig/android/feature/movingflow/SelectContractForMovingKey.kt b/app/feature/feature-movingflow-navigation/src/commonMain/kotlin/com/hedvig/android/feature/movingflow/SelectContractForMovingKey.kt new file mode 100644 index 0000000000..d1f91ca9ad --- /dev/null +++ b/app/feature/feature-movingflow-navigation/src/commonMain/kotlin/com/hedvig/android/feature/movingflow/SelectContractForMovingKey.kt @@ -0,0 +1,7 @@ +package com.hedvig.android.feature.movingflow + +import com.hedvig.android.navigation.common.HedvigNavKey +import kotlinx.serialization.Serializable + +@Serializable +data object SelectContractForMovingKey : HedvigNavKey diff --git a/app/feature/feature-movingflow/build.gradle.kts b/app/feature/feature-movingflow/build.gradle.kts index 9172268f36..66b2de627a 100644 --- a/app/feature/feature-movingflow/build.gradle.kts +++ b/app/feature/feature-movingflow/build.gradle.kts @@ -34,6 +34,7 @@ dependencies { implementation(projects.dataProductVariantPublic) implementation(projects.designSystemHedvig) implementation(projects.featureFlags) + implementation(projects.featureMovingflowNavigation) implementation(projects.moleculePublic) implementation(projects.navigationCommon) implementation(projects.navigationCompose) diff --git a/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/MovingFlowEntries.kt b/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/MovingFlowEntries.kt index 6de7995c9e..74f4cff93b 100644 --- a/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/MovingFlowEntries.kt +++ b/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/MovingFlowEntries.kt @@ -26,9 +26,6 @@ import dev.zacsweers.metrox.viewmodel.metroViewModel import kotlinx.datetime.LocalDate import kotlinx.serialization.Serializable -@Serializable -data object SelectContractForMovingKey : HedvigNavKey - @Serializable internal data class HousingTypeKey(val moveIntentId: String) : HedvigNavKey diff --git a/app/feature/feature-terminate-insurance-navigation/build.gradle.kts b/app/feature/feature-terminate-insurance-navigation/build.gradle.kts new file mode 100644 index 0000000000..288520f5a2 --- /dev/null +++ b/app/feature/feature-terminate-insurance-navigation/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + id("hedvig.multiplatform.library") + id("hedvig.multiplatform.library.android") + id("hedvig.gradle.plugin") +} + +hedvig { + serialization() + navKeys() +} + +kotlin { + sourceSets { + commonMain.dependencies { + implementation(libs.kotlinx.serialization.core) + implementation(projects.coreCommonPublic) + implementation(projects.navigationCommon) + } + } +} diff --git a/app/feature/feature-terminate-insurance-navigation/src/commonMain/kotlin/com/hedvig/android/feature/terminateinsurance/navigation/TerminateInsuranceKey.kt b/app/feature/feature-terminate-insurance-navigation/src/commonMain/kotlin/com/hedvig/android/feature/terminateinsurance/navigation/TerminateInsuranceKey.kt new file mode 100644 index 0000000000..748c695e91 --- /dev/null +++ b/app/feature/feature-terminate-insurance-navigation/src/commonMain/kotlin/com/hedvig/android/feature/terminateinsurance/navigation/TerminateInsuranceKey.kt @@ -0,0 +1,11 @@ +package com.hedvig.android.feature.terminateinsurance.navigation + +import com.hedvig.android.navigation.common.HedvigNavKey +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class TerminateInsuranceKey( + @SerialName("contractId") + val insuranceId: String? = null, +) : HedvigNavKey diff --git a/app/feature/feature-terminate-insurance/build.gradle.kts b/app/feature/feature-terminate-insurance/build.gradle.kts index a642ab4263..a6eb950575 100644 --- a/app/feature/feature-terminate-insurance/build.gradle.kts +++ b/app/feature/feature-terminate-insurance/build.gradle.kts @@ -37,6 +37,7 @@ dependencies { implementation(projects.dataTermination) implementation(projects.designSystemHedvig) implementation(projects.featureFlags) + implementation(projects.featureTerminateInsuranceNavigation) implementation(projects.languageCore) implementation(projects.moleculePublic) implementation(projects.navigationCommon) diff --git a/app/feature/feature-terminate-insurance/src/main/kotlin/com/hedvig/android/feature/terminateinsurance/navigation/TerminateInsuranceDestination.kt b/app/feature/feature-terminate-insurance/src/main/kotlin/com/hedvig/android/feature/terminateinsurance/navigation/TerminateInsuranceDestination.kt index c21d30cb57..b8be42ba3a 100644 --- a/app/feature/feature-terminate-insurance/src/main/kotlin/com/hedvig/android/feature/terminateinsurance/navigation/TerminateInsuranceDestination.kt +++ b/app/feature/feature-terminate-insurance/src/main/kotlin/com/hedvig/android/feature/terminateinsurance/navigation/TerminateInsuranceDestination.kt @@ -7,15 +7,8 @@ import com.hedvig.android.feature.terminateinsurance.data.TerminationAction import com.hedvig.android.feature.terminateinsurance.data.TerminationSurveyOption import com.hedvig.android.navigation.common.HedvigNavKey import kotlinx.datetime.LocalDate -import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -@Serializable -data class TerminateInsuranceKey( - @SerialName("contractId") - val insuranceId: String? = null, -) : HedvigNavKey - @Serializable internal data class TerminationSurveyFirstStepKey( val options: List, diff --git a/app/feature/feature-travel-certificate-navigation/build.gradle.kts b/app/feature/feature-travel-certificate-navigation/build.gradle.kts new file mode 100644 index 0000000000..288520f5a2 --- /dev/null +++ b/app/feature/feature-travel-certificate-navigation/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + id("hedvig.multiplatform.library") + id("hedvig.multiplatform.library.android") + id("hedvig.gradle.plugin") +} + +hedvig { + serialization() + navKeys() +} + +kotlin { + sourceSets { + commonMain.dependencies { + implementation(libs.kotlinx.serialization.core) + implementation(projects.coreCommonPublic) + implementation(projects.navigationCommon) + } + } +} diff --git a/app/feature/feature-travel-certificate-navigation/src/commonMain/kotlin/com/hedvig/android/feature/travelcertificate/navigation/TravelCertificateKey.kt b/app/feature/feature-travel-certificate-navigation/src/commonMain/kotlin/com/hedvig/android/feature/travelcertificate/navigation/TravelCertificateKey.kt new file mode 100644 index 0000000000..b4cf60c413 --- /dev/null +++ b/app/feature/feature-travel-certificate-navigation/src/commonMain/kotlin/com/hedvig/android/feature/travelcertificate/navigation/TravelCertificateKey.kt @@ -0,0 +1,8 @@ +package com.hedvig.android.feature.travelcertificate.navigation + +import com.hedvig.android.navigation.common.CrossSellEligibleDestination +import com.hedvig.android.navigation.common.HedvigNavKey +import kotlinx.serialization.Serializable + +@Serializable +data object TravelCertificateKey : HedvigNavKey, CrossSellEligibleDestination diff --git a/app/feature/feature-travel-certificate/build.gradle.kts b/app/feature/feature-travel-certificate/build.gradle.kts index 71892c4f96..9c7b048554 100644 --- a/app/feature/feature-travel-certificate/build.gradle.kts +++ b/app/feature/feature-travel-certificate/build.gradle.kts @@ -33,6 +33,7 @@ dependencies { implementation(projects.designSystemHedvig) implementation(projects.featureFlags) implementation(projects.languageCore) + implementation(projects.featureTravelCertificateNavigation) implementation(projects.moleculePublic) implementation(projects.navigationActivity) implementation(projects.navigationCommon) diff --git a/app/feature/feature-travel-certificate/src/main/kotlin/com/hedvig/android/feature/travelcertificate/navigation/TravelCertificateDestination.kt b/app/feature/feature-travel-certificate/src/main/kotlin/com/hedvig/android/feature/travelcertificate/navigation/TravelCertificateDestination.kt index 8c723973e5..73fd26094a 100644 --- a/app/feature/feature-travel-certificate/src/main/kotlin/com/hedvig/android/feature/travelcertificate/navigation/TravelCertificateDestination.kt +++ b/app/feature/feature-travel-certificate/src/main/kotlin/com/hedvig/android/feature/travelcertificate/navigation/TravelCertificateDestination.kt @@ -1,14 +1,10 @@ package com.hedvig.android.feature.travelcertificate.navigation import com.hedvig.android.feature.travelcertificate.data.TravelCertificateUrl -import com.hedvig.android.navigation.common.CrossSellEligibleDestination import com.hedvig.android.navigation.common.HedvigNavKey import kotlinx.datetime.LocalDate import kotlinx.serialization.Serializable -@Serializable -data object TravelCertificateKey : HedvigNavKey, CrossSellEligibleDestination - @Serializable internal data object TravelCertificateChooseContractKey : HedvigNavKey From de50d5ec3555818b19154c6639cd5d82c7d47a90 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Mon, 8 Jun 2026 10:08:41 +0300 Subject: [PATCH 04/12] Wire orphaned help-center tests into jvmTest so they run again After the module's KMP migration the tests sat in src/test, which is not attached to any compilation, so they never ran. Move them into the jvmTest source set (they rely on JUnit4 @get:Rule helpers, so commonTest isn't an option) and point the test dependencies at jvmTest. --- app/feature/feature-help-center/build.gradle.kts | 2 +- .../kotlin/GetMemberActionsUseCaseImplTest.kt} | 0 .../src/{test => jvmTest}/kotlin/GetQuickLinksUseCaseTest.kt | 0 3 files changed, 1 insertion(+), 1 deletion(-) rename app/feature/feature-help-center/src/{test/kotlin/GetMemberActionsUseCaseImpl.kt => jvmTest/kotlin/GetMemberActionsUseCaseImplTest.kt} (100%) rename app/feature/feature-help-center/src/{test => jvmTest}/kotlin/GetQuickLinksUseCaseTest.kt (100%) diff --git a/app/feature/feature-help-center/build.gradle.kts b/app/feature/feature-help-center/build.gradle.kts index 1f1e862eff..6f58c659b1 100644 --- a/app/feature/feature-help-center/build.gradle.kts +++ b/app/feature/feature-help-center/build.gradle.kts @@ -70,7 +70,7 @@ kotlin { } jvmMain.dependencies { } - androidInstrumentedTest.dependencies { + jvmTest.dependencies { implementation(libs.apollo.testingSupport) implementation(libs.assertK) implementation(libs.coroutines.test) diff --git a/app/feature/feature-help-center/src/test/kotlin/GetMemberActionsUseCaseImpl.kt b/app/feature/feature-help-center/src/jvmTest/kotlin/GetMemberActionsUseCaseImplTest.kt similarity index 100% rename from app/feature/feature-help-center/src/test/kotlin/GetMemberActionsUseCaseImpl.kt rename to app/feature/feature-help-center/src/jvmTest/kotlin/GetMemberActionsUseCaseImplTest.kt diff --git a/app/feature/feature-help-center/src/test/kotlin/GetQuickLinksUseCaseTest.kt b/app/feature/feature-help-center/src/jvmTest/kotlin/GetQuickLinksUseCaseTest.kt similarity index 100% rename from app/feature/feature-help-center/src/test/kotlin/GetQuickLinksUseCaseTest.kt rename to app/feature/feature-help-center/src/jvmTest/kotlin/GetQuickLinksUseCaseTest.kt From b8f6473b26d33cda00e6ca3d1210fcf6d3c76eae Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Mon, 8 Jun 2026 11:16:19 +0300 Subject: [PATCH 05/12] Remove comments --- .../android/feature/deleteaccount/DeleteAccountPresenter.kt | 2 -- .../android/feature/deleteaccount/DeleteAccountViewModel.kt | 3 --- 2 files changed, 5 deletions(-) diff --git a/app/feature/feature-delete-account/src/main/kotlin/com/hedvig/android/feature/deleteaccount/DeleteAccountPresenter.kt b/app/feature/feature-delete-account/src/main/kotlin/com/hedvig/android/feature/deleteaccount/DeleteAccountPresenter.kt index 3ec4c5ed5d..4e83d8ba52 100644 --- a/app/feature/feature-delete-account/src/main/kotlin/com/hedvig/android/feature/deleteaccount/DeleteAccountPresenter.kt +++ b/app/feature/feature-delete-account/src/main/kotlin/com/hedvig/android/feature/deleteaccount/DeleteAccountPresenter.kt @@ -64,8 +64,6 @@ internal class DeleteAccountPresenter( if (mutationResult.isLeft()) { failedToPerformAccountDeletion = true } else { - // Navigation driven directly from the Presenter via the injected app-scoped backstack — - // the whole point of hoisting the back stack into AppState. backstack.popBackstack() } isPerformingDeletion = false diff --git a/app/feature/feature-delete-account/src/main/kotlin/com/hedvig/android/feature/deleteaccount/DeleteAccountViewModel.kt b/app/feature/feature-delete-account/src/main/kotlin/com/hedvig/android/feature/deleteaccount/DeleteAccountViewModel.kt index b7b62a1661..a038a6b49c 100644 --- a/app/feature/feature-delete-account/src/main/kotlin/com/hedvig/android/feature/deleteaccount/DeleteAccountViewModel.kt +++ b/app/feature/feature-delete-account/src/main/kotlin/com/hedvig/android/feature/deleteaccount/DeleteAccountViewModel.kt @@ -20,9 +20,6 @@ import dev.zacsweers.metrox.viewmodel.ViewModelKey internal class DeleteAccountViewModel( private val requestAccountDeletionUseCase: RequestAccountDeletionUseCase, private val deleteAccountStateUseCase: DeleteAccountStateUseCase, - // The app-scoped backstack is injected straight into the ViewModel and handed to the Presenter, - // which can drive navigation itself (see DeleteAccountPresenter). It outlives this ViewModel, so it - // stays valid across config changes — no stale back stack. backstack: Backstack, ) : MoleculeViewModel( DeleteAccountUiState.Loading, From 25318bc70aba10b26f06a51fca019b623b7f83ac Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Mon, 8 Jun 2026 11:39:22 +0300 Subject: [PATCH 06/12] Cleanup backstack initialization into NavigationStateBridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New NavigationStateBridge.kt — a single object owning the whole Activity↔BackstackController seam: the intent escape handoff, the process-death snapshot (NavStateSnapshot + registry key + save-provider), and one restoreAndPersist(...) holding the full seeding precedence top-to-bottom. - Deleted RestoredBackstackTransfer.kt (folded in). - MainActivity shrank — the restoration ladder, NavStateSnapshot, the registry key, savedStateConfiguration, and 9 unused imports are gone, replaced by attaching the two task lambdas plus one NavigationStateBridge.restoreAndPersist(...) call. Behavior is unchanged — same precedence, same config-change no-op, same save provider. --- .../com/hedvig/android/app/MainActivity.kt | 81 +--------- .../android/app/NavigationStateBridge.kt | 140 ++++++++++++++++++ .../android/app/RestoredBackstackTransfer.kt | 48 ------ 3 files changed, 147 insertions(+), 122 deletions(-) create mode 100644 app/app/src/main/kotlin/com/hedvig/android/app/NavigationStateBridge.kt delete mode 100644 app/app/src/main/kotlin/com/hedvig/android/app/RestoredBackstackTransfer.kt diff --git a/app/app/src/main/kotlin/com/hedvig/android/app/MainActivity.kt b/app/app/src/main/kotlin/com/hedvig/android/app/MainActivity.kt index 7b1e12b6c8..ddd176b819 100644 --- a/app/app/src/main/kotlin/com/hedvig/android/app/MainActivity.kt +++ b/app/app/src/main/kotlin/com/hedvig/android/app/MainActivity.kt @@ -16,17 +16,11 @@ import androidx.appcompat.app.AppCompatDelegate import androidx.compose.foundation.ComposeFoundationFlags import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalContext import androidx.core.content.getSystemService import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle -import androidx.savedstate.SavedStateRegistry -import androidx.savedstate.serialization.SavedStateConfiguration -import androidx.savedstate.serialization.decodeFromSavedState -import androidx.savedstate.serialization.encodeToSavedState import coil3.ImageLoader import com.google.android.play.core.review.ReviewException import com.google.android.play.core.review.ReviewManagerFactory @@ -46,17 +40,12 @@ import com.hedvig.android.core.demomode.Provider import com.hedvig.android.core.rive.RiveInitializer import com.hedvig.android.data.paying.member.GetOnlyHasNonPayingContractsUseCase import com.hedvig.android.data.settings.datastore.SettingsDataStore -import com.hedvig.android.feature.login.navigation.LoginKey import com.hedvig.android.featureflags.FeatureManager import com.hedvig.android.language.LanguageLaunchCheckUseCase import com.hedvig.android.language.LanguageService import com.hedvig.android.logger.LogPriority import com.hedvig.android.logger.logcat -import com.hedvig.android.navigation.common.HedvigNavKey -import com.hedvig.android.navigation.common.StashedSession -import com.hedvig.android.navigation.common.TopLevelTab import com.hedvig.android.navigation.compose.HedvigDeepLinkMatcher -import com.hedvig.android.navigation.compose.merge import com.hedvig.android.notification.badge.data.payment.MissedPaymentNotificationService import com.hedvig.android.theme.Theme import dev.zacsweers.metro.Inject @@ -65,8 +54,6 @@ import java.util.Locale import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import kotlinx.serialization.Polymorphic -import kotlinx.serialization.Serializable import kotlinx.serialization.modules.SerializersModule class MainActivity : AppCompatActivity() { @@ -194,59 +181,20 @@ class MainActivity : AppCompatActivity() { override fun tryShowAppStoreReviewDialog() = tryShowPlayStoreReviewDialog() } RiveInitializer.init(this) - val savedStateConfiguration = SavedStateConfiguration { - serializersModule = serializersModules.merge() - } // Attach the Activity-bound task hooks to the app-scoped controller. Done here (not at // construction) and re-attached on every recreation: the singleton outlives any single Activity, // so capturing an Activity-bound lambda in the constructor would be the stale-reference leak we // set out to avoid. backstackController.isOwnTask = { isTaskRoot } backstackController.escapeToOwnTask = { parentStack -> - RestoredBackstackTransfer.escapeToOwnTask(this@MainActivity, parentStack, serializersModules) - } - val restoredBackstack = if (savedInstanceState != null) { - null - } else { - RestoredBackstackTransfer.readFrom(intent, serializersModules) + NavigationStateBridge.escapeToOwnTask(this@MainActivity, parentStack, serializersModules) } - // Seed / restore the hoisted (app-scoped) navigation state. Precedence: - // 1. An explicit deep-link / escape re-root replaces everything. - // 2. Otherwise, on a cold start after process death, re-hydrate the full state from this - // Activity's SavedStateRegistry. On a config change the live singleton is already populated, - // so restoreFromSavedState is a no-op there and the live state wins. - // 3. Finally guarantee at least a Login root. - if (!restoredBackstack.isNullOrEmpty()) { - backstackController.reseed(restoredBackstack) - } else { - savedStateRegistry.consumeRestoredStateForKey(NAV_STATE_REGISTRY_KEY) - ?.let { decodeFromSavedState(NavStateSnapshot.serializer(), it, savedStateConfiguration) } - ?.let { snapshot -> - backstackController.restoreFromSavedState( - entries = snapshot.entries, - parkedRuns = snapshot.parkedRuns, - pendingDeepLink = snapshot.pendingDeepLink, - stashedSession = snapshot.stashedSession, - ) - } - backstackController.seedIfEmpty(listOf(LoginKey)) - } - // Persist the live navigation state across process death. The provider is invoked at save time - // and serializes whatever the singleton holds then, so a Presenter-driven navigation is captured. - savedStateRegistry.registerSavedStateProvider( - NAV_STATE_REGISTRY_KEY, - SavedStateRegistry.SavedStateProvider { - encodeToSavedState( - NavStateSnapshot.serializer(), - NavStateSnapshot( - entries = backstackController.entries.toList(), - parkedRuns = backstackController.parkedRuns.toMap(), - pendingDeepLink = backstackController.pendingDeepLink, - stashedSession = backstackController.stashedSession, - ), - savedStateConfiguration, - ) - }, + NavigationStateBridge.restoreAndPersist( + backstackController = backstackController, + savedStateRegistry = savedStateRegistry, + intent = intent, + isColdStart = savedInstanceState == null, + serializersModules = serializersModules, ) setContent { CompositionLocalProvider( @@ -353,21 +301,6 @@ private fun Activity.tryShowPlayStoreReviewDialog() { } } -private const val NAV_STATE_REGISTRY_KEY = "com.hedvig.android.app.NAV_STATE" - -/** - * The full hoisted navigation state, serialized into the Activity's SavedStateRegistry so the - * in-memory [BackstackController] singleton can be re-hydrated after process death. Mirrors the four - * holders the controller owns. - */ -@Serializable -private data class NavStateSnapshot( - val entries: List<@Polymorphic HedvigNavKey>, - val parkedRuns: Map>, - val pendingDeepLink: (@Polymorphic HedvigNavKey)?, - val stashedSession: StashedSession?, -) - private fun getSystemLocale(config: android.content.res.Configuration): Locale { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { Resources.getSystem().configuration.locales[0] diff --git a/app/app/src/main/kotlin/com/hedvig/android/app/NavigationStateBridge.kt b/app/app/src/main/kotlin/com/hedvig/android/app/NavigationStateBridge.kt new file mode 100644 index 0000000000..dc85033309 --- /dev/null +++ b/app/app/src/main/kotlin/com/hedvig/android/app/NavigationStateBridge.kt @@ -0,0 +1,140 @@ +package com.hedvig.android.app + +import android.app.Activity +import android.content.Intent +import androidx.savedstate.SavedStateRegistry +import androidx.savedstate.serialization.SavedStateConfiguration +import androidx.savedstate.serialization.decodeFromSavedState +import androidx.savedstate.serialization.encodeToSavedState +import com.hedvig.android.app.navigation.BackstackController +import com.hedvig.android.feature.login.navigation.LoginKey +import com.hedvig.android.navigation.common.HedvigNavKey +import com.hedvig.android.navigation.common.StashedSession +import com.hedvig.android.navigation.common.TopLevelTab +import com.hedvig.android.navigation.compose.merge +import kotlinx.serialization.Polymorphic +import kotlinx.serialization.PolymorphicSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json +import kotlinx.serialization.modules.SerializersModule + +/** + * The single seam between an Activity lifecycle and the app-scoped [BackstackController]. Owns every + * way the navigation state crosses that seam, so the decision about what the stack should be at + * launch is made in one place rather than spread across `MainActivity`: + * + * - the Intent escape handoff that carries a backstack across an Activity relaunch ([escapeToOwnTask] + * writes it, [restoreAndPersist] reads it), + * - the process-death snapshot serialized into the Activity's `SavedStateRegistry` ([NavStateSnapshot]), + * - the launch-time seeding precedence and the save-provider registration ([restoreAndPersist]). + */ +internal object NavigationStateBridge { + private const val EXTRA_RESTORE_STACK = "com.hedvig.android.app.RESTORE_STACK" + private const val NAV_STATE_REGISTRY_KEY = "com.hedvig.android.app.NAV_STATE" + + private val handoffSerializer = ListSerializer(PolymorphicSerializer(HedvigNavKey::class)) + + /** + * Seeds / restores the hoisted navigation state, then registers the provider that persists it. + * The seeding precedence, read top to bottom: + * 1. An explicit deep-link / escape re-root (carried on the launch [intent]) replaces everything. + * 2. Otherwise, on a cold start after process death, re-hydrate the full state from the Activity's + * `SavedStateRegistry`. On a config change the live singleton is already populated, so + * [BackstackController.restoreFromSavedState] is a no-op and the live state wins. + * 3. Finally guarantee at least a Login root. + * + * [isColdStart] is `savedInstanceState == null`: a handoff only applies to a genuinely fresh launch. + */ + fun restoreAndPersist( + backstackController: BackstackController, + savedStateRegistry: SavedStateRegistry, + intent: Intent, + isColdStart: Boolean, + serializersModules: Set, + ) { + val savedStateConfiguration = SavedStateConfiguration { + this.serializersModule = serializersModules.merge() + } + + val handoff = if (isColdStart) { + readEscapeToOwnTaskHandoff(intent, serializersModules) + } else { + null + } + if (!handoff.isNullOrEmpty()) { + backstackController.reseed(handoff) + } else { + savedStateRegistry.consumeRestoredStateForKey(NAV_STATE_REGISTRY_KEY) + ?.let { decodeFromSavedState(NavStateSnapshot.serializer(), it, savedStateConfiguration) } + ?.let { snapshot -> + backstackController.restoreFromSavedState( + entries = snapshot.entries, + parkedRuns = snapshot.parkedRuns, + pendingDeepLink = snapshot.pendingDeepLink, + stashedSession = snapshot.stashedSession, + ) + } + backstackController.seedIfEmpty(listOf(LoginKey)) + } + + // Persist the live navigation state across process death. The provider is invoked at save time + // and serializes whatever the singleton holds then, so a Presenter-driven navigation is captured. + savedStateRegistry.registerSavedStateProvider(NAV_STATE_REGISTRY_KEY) { + encodeToSavedState( + NavStateSnapshot.serializer(), + NavStateSnapshot( + entries = backstackController.entries.toList(), + parkedRuns = backstackController.parkedRuns.toMap(), + pendingDeepLink = backstackController.pendingDeepLink, + stashedSession = backstackController.stashedSession, + ), + savedStateConfiguration, + ) + } + } + + /** + * Finishes this foreign-hosted instance and relaunches MainActivity in its own task, seeded with + * [parentStack]. The fresh instance reads the ancestry back in [restoreAndPersist] via the same + * extra key and codec, so the write/read contract can't drift. + */ + fun escapeToOwnTask( + activity: Activity, + parentStack: List, + serializersModules: Set, + ) { + val relaunch = Intent(activity, MainActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + putExtra(EXTRA_RESTORE_STACK, json(serializersModules).encodeToString(handoffSerializer, parentStack)) + } + activity.finish() + activity.startActivity(relaunch) + } + + /** The ancestry seeded by a prior [escapeToOwnTask], or null when [intent] carries no handoff. */ + private fun readEscapeToOwnTaskHandoff( + intent: Intent, + serializersModules: Set, + ): List? { + val encoded = intent.getStringExtra(EXTRA_RESTORE_STACK) ?: return null + return json(serializersModules).decodeFromString(handoffSerializer, encoded) + } + + private fun json(serializersModules: Set): Json = Json { + serializersModule = serializersModules.merge() + } +} + +/** + * The full hoisted navigation state, serialized into the Activity's SavedStateRegistry so the + * in-memory [BackstackController] singleton can be re-hydrated after process death. Mirrors the four + * holders the controller owns. + */ +@Serializable +private data class NavStateSnapshot( + val entries: List<@Polymorphic HedvigNavKey>, + val parkedRuns: Map>, + val pendingDeepLink: (@Polymorphic HedvigNavKey)?, + val stashedSession: StashedSession?, +) diff --git a/app/app/src/main/kotlin/com/hedvig/android/app/RestoredBackstackTransfer.kt b/app/app/src/main/kotlin/com/hedvig/android/app/RestoredBackstackTransfer.kt deleted file mode 100644 index f98e8a4b25..0000000000 --- a/app/app/src/main/kotlin/com/hedvig/android/app/RestoredBackstackTransfer.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.hedvig.android.app - -import android.app.Activity -import android.content.Intent -import com.hedvig.android.navigation.common.HedvigNavKey -import com.hedvig.android.navigation.compose.merge -import kotlinx.serialization.PolymorphicSerializer -import kotlinx.serialization.builtins.ListSerializer -import kotlinx.serialization.json.Json -import kotlinx.serialization.modules.SerializersModule - -/** - * The Intent-based handoff that carries a backstack across an Activity relaunch. - * - * When Up is pressed from a deep link hosted inside the launching app's task, [escapeToOwnTask] - * re-roots us in our own task (NEW_TASK|CLEAR_TASK + finish) and serializes the target ancestry into - * the launch intent. The fresh instance reads it back with [readFrom] to seed its initial backstack. - * Both sides share the same extra key and codec here so the write/read contract can't drift. - */ -internal object RestoredBackstackTransfer { - private const val EXTRA_RESTORE_STACK = "com.hedvig.android.app.RESTORE_STACK" - - private val serializer = ListSerializer(PolymorphicSerializer(HedvigNavKey::class)) - - /** Finishes this foreign-hosted instance and relaunches MainActivity in its own task, seeded with [parentStack]. */ - fun escapeToOwnTask( - activity: Activity, - parentStack: List, - serializersModules: Set, - ) { - val relaunch = Intent(activity, MainActivity::class.java).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) - putExtra(EXTRA_RESTORE_STACK, json(serializersModules).encodeToString(serializer, parentStack)) - } - activity.finish() - activity.startActivity(relaunch) - } - - /** The ancestry seeded by a prior [escapeToOwnTask], or null when [intent] carries no handoff. */ - fun readFrom(intent: Intent, serializersModules: Set): List? { - val encoded = intent.getStringExtra(EXTRA_RESTORE_STACK) ?: return null - return json(serializersModules).decodeFromString(serializer, encoded) - } - - private fun json(serializersModules: Set): Json = Json { - serializersModule = serializersModules.merge() - } -} From 95cbcb660e0ddb006be8a206dadd0a46efa0a521 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Mon, 8 Jun 2026 15:11:04 +0300 Subject: [PATCH 07/12] Remove AppState Store the mutableState in our AppScoped BackstackController ourselves Consider trying out AppState later when perhaps it does more things out of the box for us --- app/app/build.gradle.kts | 1 - .../app/navigation/BackstackController.kt | 35 +++++++------------ gradle/libs.versions.toml | 2 -- settings.gradle.kts | 7 ---- 4 files changed, 12 insertions(+), 33 deletions(-) diff --git a/app/app/build.gradle.kts b/app/app/build.gradle.kts index 48210a87a6..88e456445e 100644 --- a/app/app/build.gradle.kts +++ b/app/app/build.gradle.kts @@ -107,7 +107,6 @@ dependencies { implementation(platform(libs.firebase.bom)) implementation(libs.androidx.activity.compose) - implementation(libs.androidx.appstate) implementation(libs.androidx.compose.animationCore) implementation(libs.androidx.compose.foundation) implementation(libs.androidx.compose.material3.windowSizeClass) diff --git a/app/app/src/main/kotlin/com/hedvig/android/app/navigation/BackstackController.kt b/app/app/src/main/kotlin/com/hedvig/android/app/navigation/BackstackController.kt index 0a64453447..5aa5cf0740 100644 --- a/app/app/src/main/kotlin/com/hedvig/android/app/navigation/BackstackController.kt +++ b/app/app/src/main/kotlin/com/hedvig/android/app/navigation/BackstackController.kt @@ -1,7 +1,5 @@ package com.hedvig.android.app.navigation -import androidx.appstate.AppState -import androidx.appstate.AppStateKey import androidx.compose.runtime.MutableState import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue @@ -36,10 +34,9 @@ import dev.zacsweers.metro.SingleIn * `MainActivity.setContent`, so a config change recreated the Activity and deserialized it into a * brand-new instance — while Metro ViewModels survive a config change as the same instance via their * per-entry `ViewModelStore`. Handing a long-lived Presenter a reference to the composition-scoped - * stack would therefore go stale on the next rotation. Holding the state in an app-scoped [AppState] - * (see [BackstackControllerProviders]) makes this controller outlive every ViewModel, so a Presenter - * can mutate the live, rendered stack through [Backstack]. AppState is "just an object you choose the - * scope of" — here that scope is the application graph. + * stack would therefore go stale on the next rotation. Owning the snapshot state in this app-scoped + * singleton (see [BackstackControllerProviders]) makes the controller outlive every ViewModel, so a + * Presenter can mutate the live, rendered stack through [Backstack]. * * Process-death persistence is bridged at the Activity seam: an in-memory singleton is wiped when the * process dies, so `MainActivity` serializes the four holders into its `SavedStateRegistry` and @@ -313,29 +310,21 @@ private fun SnapshotStateList.replaceWith(target: List>() -private val ParkedRunsKey = AppStateKey>>() -private val PendingDeepLinkKey = AppStateKey>() -private val StashedSessionKey = AppStateKey>() - /** - * Wires the app-scoped navigation state. [AppState] is the process-lifetime store; the four nav - * holders are read out of it (created once, on first access) and handed to the singleton - * [BackstackController], which is exposed to feature Presenters as a plain [Backstack]. + * Wires the app-scoped navigation state. The four snapshot holders are created once and owned by the + * singleton [BackstackController] — their lifetime is the application graph, so they survive a config + * change while remaining the live objects the UI renders. The controller is exposed to feature + * Presenters as a plain [Backstack]. */ @ContributesTo(AppScope::class) internal interface BackstackControllerProviders { @Provides @SingleIn(AppScope::class) - fun provideAppState(): AppState = AppState() - - @Provides - @SingleIn(AppScope::class) - fun provideBackstackController(appState: AppState): BackstackController = BackstackController( - entries = appState.getState(EntriesKey, mutableStateListOf()).value, - parkedRuns = appState.getState(ParkedRunsKey, mutableStateMapOf()).value, - pendingDeepLinkState = appState.getState(PendingDeepLinkKey, mutableStateOf(null)).value, - stashedSessionState = appState.getState(StashedSessionKey, mutableStateOf(null)).value, + fun provideBackstackController(): BackstackController = BackstackController( + entries = mutableStateListOf(), + parkedRuns = mutableStateMapOf(), + pendingDeepLinkState = mutableStateOf(null), + stashedSessionState = mutableStateOf(null), ) @Provides diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3c065f28ba..901bcf2887 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -37,7 +37,6 @@ rive = "11.2.1" androidx-activity-compose = "1.13.0" androidx-activity-core = "1.13.0" androidx-annotation = "1.9.1" -androidx-appstate = "1.0.0-SNAPSHOT" androidx-composeBom = "2026.04.01" androidx-datastore = "1.2.0" androidx-junit = "1.3.0" @@ -106,7 +105,6 @@ accompanist-permissions = { module = "com.google.accompanist:accompanist-permiss androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" } androidx-activity-core = { module = "androidx.activity:activity", version.ref = "androidx-activity-core" } androidx-annotation = { module = "androidx.annotation:annotation", version.ref = "androidx-annotation" } -androidx-appstate = { module = "androidx.appstate:appstate", version.ref = "androidx-appstate" } androidx-compose-animation = { module = "androidx.compose.animation:animation" } androidx-compose-animationCore = { module = "androidx.compose.animation:animation-core" } androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidx-composeBom" } diff --git a/settings.gradle.kts b/settings.gradle.kts index c1e5326017..9aac034f85 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -25,13 +25,6 @@ dependencyResolutionManagement { } mavenCentral() maven("https://jitpack.io") - // SPIKE: androidx.dev snapshot build 15580488, only for the unreleased androidx.appstate preview. - maven("https://androidx.dev/snapshots/builds/15580488/artifacts/repository") { - mavenContent { - includeGroup("androidx.appstate") - snapshotsOnly() - } - } } } From 15e47488e223f65fe8ede450c7e97c2c299b5164 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Mon, 8 Jun 2026 16:31:06 +0300 Subject: [PATCH 08/12] Inject BackstackController into SessionReconciler --- .../app/navigation/SessionReconciler.kt | 19 +++++++++++-------- .../com/hedvig/android/app/ui/HedvigApp.kt | 4 ++-- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/app/app/src/main/kotlin/com/hedvig/android/app/navigation/SessionReconciler.kt b/app/app/src/main/kotlin/com/hedvig/android/app/navigation/SessionReconciler.kt index 02b908368f..e92b9b3ff8 100644 --- a/app/app/src/main/kotlin/com/hedvig/android/app/navigation/SessionReconciler.kt +++ b/app/app/src/main/kotlin/com/hedvig/android/app/navigation/SessionReconciler.kt @@ -3,6 +3,7 @@ package com.hedvig.android.app.navigation import androidx.compose.runtime.snapshotFlow import androidx.lifecycle.Lifecycle import androidx.lifecycle.repeatOnLifecycle +import arrow.core.identity import arrow.fx.coroutines.raceN import com.hedvig.android.auth.AuthStatus import com.hedvig.android.auth.AuthTokenService @@ -33,12 +34,14 @@ import kotlinx.coroutines.launch * longer hold tokens. It is lifecycle-gated so it only observes while the UI is STARTED. * * Deliberately narrow — only auth and the backstack root. Deep-links, notifications and the rest stay - * in their own observers. The [BackstackController] and the [Lifecycle] are handed in per call by the - * composition rather than injected, keeping this reconciler free of any Android/Compose lifetime. + * in their own observers. The [BackstackController] is an app-scoped singleton injected here; only the + * [Lifecycle] is handed in per call by the composition, keeping this reconciler free of any + * Android/Compose lifetime. */ @SingleIn(AppScope::class) @Inject internal class SessionReconciler( + private val backstackController: BackstackController, private val authTokenService: AuthTokenService, private val demoManager: DemoManager, private val memberIdService: MemberIdService, @@ -54,10 +57,10 @@ internal class SessionReconciler( * Resolves the start scene once, before the splash is dismissed. A no-op on subsequent calls because * [isReady] has already latched true. */ - suspend fun reconcile(backstackController: BackstackController) { + suspend fun reconcile() { if (isReadyState.value) return memberIdService.getMemberId().first()?.let { lastKnownMemberId = it } - determineStartScene(backstackController) + determineStartScene() isReadyState.value = true } @@ -65,14 +68,14 @@ internal class SessionReconciler( * Lifecycle-gated observers that keep the rendered root honest while the UI is STARTED: tracks the * latest member id and logs out when we leave demo mode without holding tokens. */ - suspend fun observeForcedLogout(backstackController: BackstackController, lifecycle: Lifecycle) { + suspend fun observeForcedLogout(lifecycle: Lifecycle) { lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { launch { memberIdService.getMemberId().collect { id -> if (id != null) lastKnownMemberId = id } } - logoutOnInvalidCredentials(backstackController) + logoutOnInvalidCredentials() } } @@ -81,7 +84,7 @@ internal class SessionReconciler( * restore the back stack already reflects the previous session, so a matching root is left untouched * and any deeper stack is preserved. */ - private suspend fun determineStartScene(backstackController: BackstackController) { + private suspend fun determineStartScene() { val showLoggedInScene = raceN( { authTokenService.authStatus.filterNotNull().first() is AuthStatus.LoggedIn }, { demoManager.isDemoMode().first { it } }, @@ -103,7 +106,7 @@ internal class SessionReconciler( * Automatically logs out when we are no longer in demo mode and we are also not considered to have * active tokens. */ - private suspend fun logoutOnInvalidCredentials(backstackController: BackstackController) { + private suspend fun logoutOnInvalidCredentials() { val authStatusLog: (AuthStatus?) -> Unit = { authStatus -> logcat { buildString { diff --git a/app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigApp.kt b/app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigApp.kt index de643d6f54..0c10ca4dc2 100644 --- a/app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigApp.kt +++ b/app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigApp.kt @@ -135,8 +135,8 @@ internal fun HedvigApp( goToPlayStore = externalNavigator::tryOpenPlayStore, ) } else { - LaunchedEffect(sessionReconciler, backstackController, lifecycle) { - sessionReconciler.observeForcedLogout(backstackController, lifecycle) + LaunchedEffect(sessionReconciler, lifecycle) { + sessionReconciler.observeForcedLogout(lifecycle) } TryShowAppStoreReviewDialogEffect( authTokenService, From 223cba8879576bbeff287a7e2e68a7a77a2474bd Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 9 Jun 2026 01:40:51 +0300 Subject: [PATCH 09/12] Drive session reconciliation from onCreate instead of composition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auth↔root reconciliation is an Activity-startup/lifecycle concern, not a UI one: reconcile() only reads injected singletons and gates the splash via isReady, and observeForcedLogout() carries its own repeatOnLifecycle gate. Hosting them in HedvigApp's LaunchedEffects meant reconcile() could not start until the first composition pass, holding the splash longer than necessary. Move both onto lifecycleScope in onCreate, right after the back stack is seeded, sequenced so the start scene resolves before the forced-logout watcher maintains it. HedvigApp no longer needs the SessionReconciler parameter and goes back to being purely about rendering. --- .../main/kotlin/com/hedvig/android/app/MainActivity.kt | 5 ++++- .../main/kotlin/com/hedvig/android/app/ui/HedvigApp.kt | 9 --------- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/app/app/src/main/kotlin/com/hedvig/android/app/MainActivity.kt b/app/app/src/main/kotlin/com/hedvig/android/app/MainActivity.kt index ddd176b819..96bb562161 100644 --- a/app/app/src/main/kotlin/com/hedvig/android/app/MainActivity.kt +++ b/app/app/src/main/kotlin/com/hedvig/android/app/MainActivity.kt @@ -196,6 +196,10 @@ class MainActivity : AppCompatActivity() { isColdStart = savedInstanceState == null, serializersModules = serializersModules, ) + lifecycleScope.launch { + sessionReconciler.reconcile() + sessionReconciler.observeForcedLogout(lifecycle) + } setContent { CompositionLocalProvider( LocalMetroViewModelFactory provides (application as HedvigApplication).appGraph.metroViewModelFactory, @@ -203,7 +207,6 @@ class MainActivity : AppCompatActivity() { val windowSizeClass = calculateWindowSizeClass(this@MainActivity) HedvigApp( backstackController = backstackController, - sessionReconciler = sessionReconciler, deepLinkChannel = deepLinkChannel, windowSizeClass = windowSizeClass, settingsDataStore = settingsDataStore, diff --git a/app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigApp.kt b/app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigApp.kt index 0c10ca4dc2..839beb7ddf 100644 --- a/app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigApp.kt +++ b/app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigApp.kt @@ -46,7 +46,6 @@ import com.hedvig.android.app.crosssell.CrossSellUriOpener import com.hedvig.android.app.crosssell.GetMemberAuthorizationCodeUseCase import com.hedvig.android.app.navigation.BackstackController import com.hedvig.android.app.navigation.CurrentDestinationHolder -import com.hedvig.android.app.navigation.SessionReconciler import com.hedvig.android.app.navigation.hedvigEntryProvider import com.hedvig.android.app.navigation.shouldFadeThrough import com.hedvig.android.app.urihandler.DeepLinkFirstUriHandler @@ -91,7 +90,6 @@ import org.jetbrains.compose.resources.stringResource @Composable internal fun HedvigApp( backstackController: BackstackController, - sessionReconciler: SessionReconciler, deepLinkChannel: Channel, windowSizeClass: WindowSizeClass, settingsDataStore: SettingsDataStore, @@ -122,10 +120,6 @@ internal fun HedvigApp( featureManager = featureManager, missedPaymentNotificationServiceProvider = missedPaymentNotificationServiceProvider, ) - val lifecycle = LocalLifecycleOwner.current.lifecycle - LaunchedEffect(sessionReconciler, backstackController) { - sessionReconciler.reconcile(backstackController) - } val darkTheme = hedvigAppState.darkTheme HedvigTheme(darkTheme = darkTheme) { EnableEdgeToEdgeSideEffect(darkTheme, splashIsRemovedSignal, androidAppHost::applyEdgeToEdgeStyle) @@ -135,9 +129,6 @@ internal fun HedvigApp( goToPlayStore = externalNavigator::tryOpenPlayStore, ) } else { - LaunchedEffect(sessionReconciler, lifecycle) { - sessionReconciler.observeForcedLogout(lifecycle) - } TryShowAppStoreReviewDialogEffect( authTokenService, waitUntilAppReviewDialogShouldBeOpenedUseCase, From 1d690c3455b3f25b4b02cb62ec889889727c72cd Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 9 Jun 2026 01:41:07 +0300 Subject: [PATCH 10/12] update CLAUDE.md to new app state --- CLAUDE.md | 321 +++++++++++++++++++++++++++++++++--------------------- 1 file changed, 194 insertions(+), 127 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5bb885c21e..8ad71441a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,6 +6,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Hedvig Android app - A modern Android application built with Jetpack Compose, Apollo GraphQL, and Kotlin. The app uses a highly modular architecture with 80+ modules organized into feature, data, and core layers. +Two foundations are newer than most of the codebase and are easy to get wrong if you assume the old patterns: + +- **Dependency injection is Metro** (`dev.zacsweers.metro`), a compile-time DI framework. **Koin is gone.** If you find yourself writing a `module { }` or calling `get()`, stop — you are following a stale pattern. +- **Navigation is Navigation 3** (`androidx.navigation3`) on top of a single app-owned back stack. **There is no `NavController`, no `NavHost`, no route strings, no `navgraph`/`navdestination`.** Destinations are `@Serializable` keys; the back stack is a plain mutable list of keys. + +A full narrative of *why* these look the way they do — the engineering decisions, the alternatives rejected, the invariants — lives in `docs/architecture/navigation-and-di.md`. Read it before making structural changes to navigation or DI. This file is the day-to-day quick reference. + ## Essential Setup Commands ### Initial Setup @@ -64,30 +71,32 @@ Hedvig Android app - A modern Android application built with Jetpack Compose, Ap The codebase is organized under `/app` with 80+ modules following a strict modularization pattern: -- **app/** - Main application module +- **app/** - Main application module. Owns the single Metro `AppGraph`, the single `NavDisplay`, the back stack controller, and all cross-feature navigation wiring. - **feature/** - Feature modules (feature-home, feature-chat, feature-login, etc.) +- **feature/feature-{name}-navigation/** - Tiny modules holding *only* the public `@Serializable HedvigNavKey`s of a feature that other features may navigate to. These are the one carve-out to the "features can't depend on features" rule. - **data/** - Data layer modules (data-contract, data-chat, data-addons, etc.) - **core/** - Core utilities (core-common, core-datastore, core-resources, etc.) - **apollo/** - GraphQL client modules (apollo-octopus-public, apollo-core, etc.) -- **navigation/* - Navigation modules (navigation-compose, navigation-core, etc.) -- **design-system/* - Design system components +- **navigation/** - Navigation infrastructure: `navigation-common` (KMP, holds `HedvigNavKey` + marker interfaces), `navigation-compose` (KMP, the `Backstack` interface, deep-link matching, decorators), `navigation-keys-processor` (KSP processor generating serializer registrations), `navigation-activity` (Android `ExternalNavigator`). +- **design-system/** - Design system components - **ui/** - Shared UI components - **auth/** - Authentication modules - **database/** - Room database modules - **language/** - Localization modules +- **shareddi/** - KMP module declaring the iOS `IosGraph` (Metro graph for the iOS target). - **Other utilities** - payment, tracking, logging, featureflags, etc. -**Critical architectural rule:** Feature modules CANNOT depend on other feature modules. This is enforced at build time by the `hedvig.gradle.plugin`. +**Critical architectural rule:** Feature modules CANNOT depend on other feature modules. This is enforced at build time by `hedvig.gradle.plugin` (`configureFeatureModuleGuidelines()`). The single exception: any module can depend on a `feature-{name}-navigation` module, because those exist precisely to be shared cross-feature. ### Module Naming Conventions - `{name}-public` - Public APIs and interfaces (often KMP-compatible) - `{name}-android` - Android-specific implementations - `{name}-test` - Test utilities +- `feature-{name}-navigation` - Public navigation keys of a feature (cross-feature depend-able) - No suffix for main implementation modules -However, if a module is KMP compatible, there is no need for the `-public` or `-android` suffix. -The android-specific code lives inside the `androidMain` directory instead. (see `:language-core`) +If a module is KMP compatible, there is no need for the `-public` or `-android` suffix. The android-specific code lives inside the `androidMain` directory instead (see `:language-core`). ### Build Types @@ -99,21 +108,21 @@ The android-specific code lives inside the `androidMain` directory instead. (see ### MVI with Molecule -The app uses Molecule (Cash App's library) for reactive state management: +The app uses Molecule (Cash App's library) for reactive state management. This is unchanged by the Metro/Nav3 migration: ```kotlin // ViewModels delegate to Presenters class FeatureViewModel( - useCaseProvider: Provider, + useCase: FeatureUseCase, ) : MoleculeViewModel( - FeatureUiState.Loading, - FeaturePresenter(/* ... */), + initialState = FeatureUiState.Loading, + presenter = FeaturePresenter(useCase), ) // Presenters contain presentation logic class FeaturePresenter : MoleculePresenter { @Composable - override fun present(events: Flow): FeatureUiState { + override fun MoleculePresenterScope.present(lastState: FeatureUiState): FeatureUiState { // Composable state management logic } } @@ -121,80 +130,138 @@ class FeaturePresenter : MoleculePresenter { **Flow:** User Action → Event → Presenter → UiState → UI -### Feature Module Pattern +### Dependency Injection (Metro) -Each feature module follows this structure: +Metro is a **compile-time** DI framework. There is a single graph, `AppScope`, for the whole app — no subscoping. Bindings are *contributed* from any module and merged into that one graph at compile time. -``` -feature-{name}/ -├── build.gradle.kts -└── src/main/kotlin/com/hedvig/android/feature/{name}/ - ├── ui/ - │ ├── {Name}Destination.kt # Composable entry point - │ ├── {Name}ViewModel.kt # MoleculeViewModel - │ ├── {Name}Presenter.kt # MoleculePresenter - │ └── {Name}Layout.kt # UI components - ├── navigation/ - │ └── {Name}Graph.kt # Navigation setup - └── di/ - └── {Name}Module.kt # Koin DI module -``` +**Core annotations you will actually use:** -### Navigation +- `@Inject` — constructor (or, on `MainActivity`/Application/Service, field) injection. +- `@SingleIn(AppScope::class)` — a singleton within the app graph. Apply to anything that must have exactly one instance (stateful services, caches, the back stack controller). +- `@ContributesBinding(AppScope::class)` — on an implementation class, binds it to its interface in the graph. The standard way to provide an `Impl` for an interface. +- `@Provides` inside a `@ContributesTo(AppScope::class) interface` — for bindings you can't annotate a constructor on (framework types, builders, things needing configuration). See `ApplicationMetroProviders`. +- `@ContributesIntoSet` / `@ContributesIntoMap` — multibindings. Used for sets of `SerializersModule`, deep-link matcher providers, notification senders, and the ViewModel/worker maps. +- `@Multibinds(allowEmpty = true)` — declares a multibound collection on the graph even when no module contributes to it. +- `@AssistedInject` + `@AssistedFactory` — runtime parameters (e.g. a screen's `contractId`) combined with injected dependencies. -Uses type-safe Navigation Compose with custom extensions: +**The app graph** is declared once, in `:app`: ```kotlin -// Destinations are serializable sealed interfaces -sealed interface FeatureDestination : Destination { - @Serializable - data object Graph : FeatureDestination - - @Serializable - data class Detail(val id: String) : FeatureDestination +@DependencyGraph(AppScope::class) +internal interface AppGraph : ViewModelGraph { + val workerFactory: MetroWorkerFactory + @Multibinds(allowEmpty = true) + val serializersModules: Set + fun inject(activity: MainActivity) + fun inject(application: HedvigApplication) + @DependencyGraph.Factory + interface Factory { fun create(@Provides applicationContext: Context): AppGraph } } +``` + +**ViewModels are resolved through Metro, not `viewModel()`:** Inside an `entry { }` block use: + +```kotlin +// No runtime args: +val vm: InsuranceViewModel = metroViewModel() + +// With assisted (navigation) args: +val vm: ContractDetailViewModel = + assistedMetroViewModel { + create(key.contractId) + } +``` -// Navigation graphs -fun NavGraphBuilder.featureGraph( - navigator: Navigator, -) { - navgraph { - navdestination { backstackEntry -> - // Composable UI - } +To register a ViewModel, contribute its factory into the graph map: + +```kotlin +@AssistedInject +internal class ContractDetailViewModel( + @Assisted contractId: String, + useCase: GetContractForContractIdUseCase, +) : MoleculeViewModel<...>(...) { + @AssistedFactory + @ManualViewModelAssistedFactoryKey + @ContributesIntoMap(AppScope::class) + fun interface Factory : ManualViewModelAssistedFactory { + fun create(@Assisted contractId: String): ContractDetailViewModel } } ``` -**Top-level navigation graphs:** Home, Insurances, Forever, Payments, Profile +A no-arg ViewModel uses `@Inject` + `@ContributesIntoMap(AppScope::class)` + `@ViewModelKey(MyViewModel::class)` instead. The central `AppViewModelFactory` (a `MetroViewModelFactory`) is provided into the composition via `LocalMetroViewModelFactory` in `MainActivity`. -### Dependency Injection +**Demo mode** is the one place we need two implementations of the same type. Use the `Provider` fun interface and a `ProdOrDemoProvider` (always `@SingleIn(AppScope::class)`), which picks `demoImpl` vs `prodImpl` off `DemoManager`. Inject `Provider` and call `.provide()`. Do **not** reach for `Provider` for anything else. + +**WorkManager** workers are built through `MetroWorkerFactory`, a multibound `Map, ChildWorkerFactory>`. A worker contributes an `@AssistedFactory` `ChildWorkerFactory` keyed with `@WorkerKey`. + +**Required Gradle flag:** `metro.generateContributionProviders=true` in `gradle.properties`. The Metro compiler plugin is auto-applied to every module by `hedvig.gradle.plugin` (`configureMetro`), so module `build.gradle.kts` files never apply it manually. + +### Navigation (Navigation 3) + +There is **one** `NavDisplay`, in `HedvigApp`, rendering a **single back stack** that is a `SnapshotStateList`. There is no `NavController` and no route strings. -Uses Koin with modular configuration: +**Destinations are keys.** A destination is a `@Serializable` class/object implementing `HedvigNavKey`: ```kotlin -// Each module has its own DI module -val featureModule = module { - viewModel { FeatureViewModel(get()) } - single { FeatureUseCase(get(), get()) } +@Serializable +data object InsurancesKey : HedvigNavKey, CrossSellEligibleDestination, TopLevelTabRoot { + override val topLevelTab = TopLevelTab.Insurances } -// All modules are included in ApplicationModule -val applicationModule = module { - includes( - featureModule, - dataModule, - networkModule, - // ... 40+ modules - ) +@Serializable +internal data class InsuranceContractDetailKey(val contractId: String) : HedvigNavKey, DeepLinkAncestry, CrossSellEligibleDestination { + override val owningTab = TopLevelTab.Insurances + override val syntheticParents = emptyList() } ``` -**Patterns:** -- Use `Provider` when we need a different implementation for the demo mode of the App, which we very rarely do. We always do that using `ProdOrDemoProvider` -- Each feature/data module has its own DI module -- Common dependencies (logging, tracking) auto-injected by build plugin -- When a Presenter or ViewModel needs to call a use case, always inject the use case directly as a typed dependency — never abstract it into an anonymous `suspend () -> T` lambda. If two separate operations are needed (e.g. payin vs payout setup), create two separate, dedicated use case classes and two separate presenters. Do not create a shared interface just to enable reuse through a single presenter. +Keys reachable cross-feature live in the feature's `-navigation` module and are public. Keys internal to a feature stay `internal` in the feature module. + +**Marker interfaces** (in `navigation-common`) let `:app` reason about a key without depending on the feature: +- `TopLevelTabRoot` — this key is the root of a bottom-nav tab (exposes `topLevelTab`). +- `DeepLinkAncestry` — how to build a synthetic back stack when this key is entered alone (exposes `owningTab` + `syntheticParents`). +- `CrossSellEligibleDestination` — the cross-sell sheet may appear here. +- `SuppressesChatPushNotification` — suppress chat push while this screen is shown. +- `DeliberateLogoutOrigin` — reaching logout from here is intentional; don't stash the session for restore. + +**The back stack API.** Presenters and entries receive the `Backstack` interface (the `:app` `BackstackController` is bound to it). `entries` is the source of truth; helpers are extensions: + +```kotlin +backstack.add(ChatKey(id)) // push +backstack.popBackstack() // pop one +backstack.popUpTo(inclusive = true) +backstack.navigateAndPopUpTo(BarKey, inclusive = true) +backstack.navigateUp() // task-aware up (deep links) +backstack.removeAllOf() +``` + +Never hold a long-lived reference to `entries` snapshot contents; mutate through the controller/extensions so changes are observed and persisted. + +**Registering destinations.** Each feature exposes a `fun EntryProviderScope.featureEntries(...)` that calls `entry { }` for each of its screens. `:app` calls all of them from `hedvigEntryProvider`. Cross-feature navigation is done by `:app` passing `navigateToX` lambdas into each feature's entries function — features never import each other's keys. + +```kotlin +fun EntryProviderScope.insuranceEntries(backstack: Backstack, /* navigateToX lambdas */) { + entry(metadata = NavSuiteSceneDecoratorStrategy.showNavBar()) { + val vm: InsuranceViewModel = metroViewModel() + InsuranceDestination(viewModel = vm, /* ... */) + } +} +``` + +**Process-death survival is automatic but requires opt-in per module.** Add `navKeys()` to the module's `hedvig { }` block. The `navigation-keys-processor` KSP processor finds every concrete `@Serializable HedvigNavKey` in the module and generates a Metro `@ContributesIntoSet SerializersModule` provider registering them polymorphically. `:app` merges all contributed modules and uses them to (de)serialize the back stack into the Activity's `SavedStateRegistry`. **If you add a key but forget `navKeys()`, the app will crash on restore** with a missing polymorphic serializer. + +**Multiple back stacks (tabs)** are handled by the "runs model" in `BackstackController`/`TopLevelRunLogic`: Home's run is always at the base of `entries`; side tabs are parked in `parkedRuns` when you switch away and restored when you switch back. Tab state (saveable state + ViewModels) of parked runs is kept alive by the retained `NavEntryDecorator`s, which consult `allLiveContentKeys`. + +**Where the heavy logic lives** (read these, don't reinvent): +- `BackstackController.kt` — app-scoped singleton, owns all nav state, tab switching, login/logout stash, deep-link routing, task-aware Up. +- `NavigationStateBridge.kt` — the single seam between Activity lifecycle and the controller (seed/restore/persist + escape-to-own-task handoff). +- `SessionReconciler.kt` — auth↔back-stack reconciliation; gates the splash via `isReady`; forced logout. +- `HedvigEntryProvider.kt` — all destination registration and cross-feature lambda wiring. + +### Deep Links + +Each feature builds `DeepLinkMatcher`s from its `HedvigDeepLinkContainer` patterns and contributes a `DeepLinkMatcherProvider` (`@ContributesIntoSet`). `:app` aggregates them into one `HedvigDeepLinkMatcher`. `MainActivity` forwards `ACTION_VIEW` intents as raw URI strings down a `deepLinkChannel`; `HedvigApp` matches each to a key and routes it through the controller once logged in. A `DeepLinkAncestry` key entered while logged out is held as `pendingDeepLink` and landed after login. ### Data Layer @@ -203,23 +270,23 @@ Data modules follow this structure when they are not KMP compatible: ``` data-{domain}/ ├── data-{domain}-public/ # Interfaces/models -│ └── src/main/ └── data-{domain}-android/ # Android implementation (optional) ``` -And they follow this structure when they are KMP compatible: +And this structure when they are KMP compatible: ``` data-{domain}/ -└── data-{domain}/ # Interfaces/models (KMP) - └── src/commonMain/ +└── data-{domain}/ # Interfaces/models (KMP), androidMain for android-specific code ``` **Patterns:** -- Repository pattern with interfaces -- Apollo GraphQL queries/mutations -- Use cases for business logic -- Room database for local persistence +- Repository pattern with interfaces, bound via `@ContributesBinding(AppScope::class)`. +- Apollo GraphQL queries/mutations. +- Use cases for business logic, injected directly as typed dependencies. +- Room database for local persistence. + +When a Presenter or ViewModel needs a use case, inject it directly as a typed dependency — never abstract it into an anonymous `suspend () -> T` lambda. If two separate operations are needed (e.g. payin vs payout setup), create two separate, dedicated use case classes and two separate presenters. Do not create a shared interface just to enable reuse through a single presenter. **Critical architectural rule — never expose GraphQL types in public API:** @@ -230,7 +297,7 @@ Use cases and repositories should: 2. Map the response into a project-owned type (a plain Kotlin `data class`, sealed type, primitive, or `Unit` if only success/failure matters) before returning. 3. Keep the `octopus.*` import confined to the `internal` impl class only. -This applies even when the GraphQL type happens to be a perfect shape — wrap it. It keeps the rest of the project insulated from schema churn, makes the data source swappable, and prevents GraphQL types from leaking into KMP/iOS-facing APIs where they'd be even more awkward. +This applies even when the GraphQL type happens to be a perfect shape — wrap it. It keeps the rest of the project insulated from schema churn, makes the data source swappable, and prevents GraphQL types from leaking into KMP/iOS-facing APIs. Example — wrong: ```kotlin @@ -256,12 +323,11 @@ internal class SetArticleRatingUseCaseImpl(...) : SetArticleRatingUseCase { } ``` -When the response carries useful structured data, define a project-owned `data class` next to the use case (or in a shared model file) and map field-by-field in the impl. - ## Technology Stack ### UI - **Jetpack Compose** - 100% Compose, no XML layouts +- **Navigation 3** (`androidx.navigation3`) - single `NavDisplay` over an app-owned back stack - **Material 3** - Window size classes, theming. Only used internally by our design-system-internals - **Coil** - Image loading (SVG, GIF, PDF support) - **ExoPlayer** (Media3) - Video playback @@ -278,11 +344,14 @@ When the response carries useful structured data, define a project-owned `data c - **Kotlin Coroutines** - Asynchronous programming - **Kotlin Flow** - Reactive streams - **Molecule** - Reactive state management -- **Arrow** - Functional programming utilities (Either, etc.) +- **Arrow** - Functional programming utilities (Either, raceN, etc.) + +### Dependency Injection +- **Metro** (`dev.zacsweers.metro`) - compile-time DI, single `AppScope` graph +- **metro-viewmodel / metro-viewmodel-compose** - ViewModel resolution on Android ### Other -- **Koin** - Dependency injection (with BOMs) -- **kotlinx.serialization** - JSON serialization +- **kotlinx.serialization** - JSON serialization, polymorphic back-stack persistence - **Timber** - Logging - **Datadog** - Analytics and RUM - **Firebase** - Crashlytics, Analytics, Messaging @@ -295,16 +364,17 @@ When the response carries useful structured data, define a project-owned `data c The project uses custom Gradle convention plugins for consistent configuration: - **hedvig.gradle.plugin** - Base plugin with: - - Feature module dependency enforcement (features can't depend on features) + - Feature module dependency enforcement (features can't depend on features, except `-navigation` modules) + - Auto-application of the **Metro** compiler plugin to every Kotlin module + - Auto-addition of metro-viewmodel deps to Android modules - Ktlint configuration - - Common dependencies (Koin BOM, Compose BOM, OkHttp BOM) - - Auto-injection of logging and tracking + - Common dependencies (Compose BOM, logging, tracking auto-injected) - **hedvig.android.application** - Android app configuration - **hedvig.android.library** - Android library configuration - **hedvig.jvm.library** - Pure Kotlin (JVM) libraries - **hedvig.multiplatform.library** - KMP support -- **hedvig.multiplatform.library.android** - used in conjuction with hedvig.multiplatform.library to add an android target to that module when it needs to have android-specific code which can not be just jvm code instead +- **hedvig.multiplatform.library.android** - adds an android target to a KMP module when it needs android-specific code ### HedvigGradlePluginExtension DSL @@ -317,15 +387,15 @@ plugins { } hedvig { - apollo("octopus") // Enable Apollo codegen - compose() // Enable Jetpack Compose - serialization() // Enable kotlinx.serialization - androidResources() // Enable Android resources - room(false) { /* config */ } // Enable Room database + apollo("octopus") // Enable Apollo codegen with the given generated package + compose() // Enable Jetpack Compose + serialization() // Enable kotlinx.serialization + androidResources() // Enable Android resources + room(false) { ... } // Enable Room database + navKeys() // Wire the nav-keys KSP processor (REQUIRED if the module declares HedvigNavKeys) } dependencies { - // Use type-safe project accessors implementation(projects.coreCommonPublic) implementation(projects.navigationCompose) implementation(projects.designSystemHedvig) @@ -352,7 +422,8 @@ Configuration in `.editorconfig`: - **Regular functions:** camelCase - **ViewModels:** `{Feature}ViewModel` - **Presenters:** `{Feature}Presenter` -- **Destinations:** `{Feature}Destination` +- **Destinations / nav keys:** `{Feature}Key` (e.g. `InsurancesKey`, `ChatKey`) +- **Entry functions:** `{feature}Entries` - **Use cases:** `{Action}{Domain}UseCase` (e.g., `GetHomeDataUseCase`) ## Working with GraphQL @@ -373,20 +444,7 @@ Configuration in `.editorconfig`: ### Writing GraphQL Queries -Place `.graphql` files in module's `src/main/graphql/`: - -```graphql -# GetHomeData.graphql -query GetHomeData { - currentMember { - id - firstName - lastName - } -} -``` - -Apollo generates type-safe Kotlin code automatically. +Place `.graphql` files in module's `src/main/graphql/`. Apollo generates type-safe Kotlin code automatically. Keep generated `octopus.*` types confined to internal impl classes (see the data layer rule above). ## Testing @@ -400,16 +458,14 @@ Apollo generates type-safe Kotlin code automatically. # Run unit tests only ./gradlew testDebugUnitTest - -# Run with coverage (if configured) -./gradlew testDebugUnitTestCoverage ``` **Test patterns:** -- Unit tests: `src/test/kotlin/` +- Unit tests: `src/test/kotlin/` (or `src/commonTest/` for KMP) - Android tests: `src/androidTest/kotlin/` - Use Turbine for testing Flows - Use test modules for shared test utilities +- Navigation invariants are covered by `ExhaustiveBackStackSerializationTest` (every `HedvigNavKey` round-trips through serialization) and `BackstackTest`. If you add a key, these guard process-death survival. ## CI/CD @@ -424,9 +480,13 @@ GitHub Actions workflows (in `.github/workflows/`): ## Important Files -- **build-logic/convention/** - Gradle convention plugins +- **build-logic/convention/** - Gradle convention plugins (Metro wiring, feature isolation, the `hedvig {}` DSL) +- **app/app/.../di/AppGraph.kt** - the single Metro graph +- **app/app/.../navigation/BackstackController.kt** - the single source of navigation truth +- **app/navigation/** - navigation infrastructure + KSP processor +- **docs/architecture/navigation-and-di.md** - the deep design spec for navigation + DI - **settings.gradle.kts** - Module discovery and configuration -- **gradle.properties** - Project properties +- **gradle.properties** - Project properties (`metro.generateContributionProviders=true` lives here) - **.editorconfig** - Code style configuration ## Common Tasks @@ -443,6 +503,7 @@ plugins { hedvig { compose() + navKeys() // if the module declares any HedvigNavKey apollo("octopus") // if needed } @@ -452,31 +513,31 @@ dependencies { implementation(projects.designSystemHedvig) } ``` -3. Create standard structure: `ui/`, `navigation/`, `di/` -4. Module will be auto-discovered by `settings.gradle.kts` +3. Create standard structure: `ui/`, `navigation/`, `di/` (contributions live next to the classes they bind via Metro annotations, not in a central `di` module). +4. Define `@Serializable` `HedvigNavKey`s. Put any cross-feature-reachable keys in a `feature-{name}-navigation` module. +5. Expose a `fun EntryProviderScope.{name}Entries(...)` and call it from `HedvigEntryProvider` in `:app`. +6. Module will be auto-discovered by `settings.gradle.kts`. -### Adding a New Data Module +### Adding a New Screen/Destination -1. Create `-public` module for interfaces (KMP-compatible) -2. Create `-android` module for implementations (if needed) -3. Add Koin module in `di/` -4. Use Repository pattern for data access +1. Define `@Serializable {Name}Key : HedvigNavKey` (add marker interfaces as needed). +2. Register it: `entry<{Name}Key> { key -> ... }` in the feature's entries function. +3. Resolve the ViewModel with `metroViewModel()` / `assistedMetroViewModel(...)`. +4. Ensure the module has `navKeys()` so the key survives process death. +5. For cross-feature entry, thread a `navigateToX` lambda from `:app` rather than importing the key. ### Adding a New GraphQL Query -1. Create `.graphql` file in `src/main/graphql/` -2. Enable Apollo in `build.gradle.kts`: `hedvig { apollo("octopus") }` -3. Build generates type-safe Kotlin code -4. Use generated query class in repository/use case +1. Create `.graphql` file in `src/main/graphql/`. +2. Enable Apollo in `build.gradle.kts`: `hedvig { apollo("octopus") }`. +3. Build generates type-safe Kotlin code. +4. Use the generated query in an internal repository/use case impl; return a project-owned type. ### Working with Translations ```bash # Download latest translations ./gradlew downloadStrings - -# Translations are managed via Lokalise -# String resources in app/core/core-resources/ ``` **IMPORTANT:** String resource XML files (`strings.xml`) are fully managed by Lokalise and regenerated on every `./gradlew downloadStrings` run. **Never add new strings directly to any `strings.xml` file** — they will be overwritten and lost. @@ -505,13 +566,19 @@ Text("This is some text for feature X") ./gradlew downloadStrings ``` +**App crashes on process-death restore / "polymorphic serializer not found":** +- The module declaring the key is missing `navKeys()` in its `hedvig {}` block, or the key isn't `@Serializable`. + +**Metro "cannot find binding" / duplicate binding errors:** +- Check the type is contributed (`@ContributesBinding`/`@Provides`/`@ContributesIntoMap`) into `AppScope`. +- Confirm `metro.generateContributionProviders=true` is present in `gradle.properties`. + **Dependency resolution failures:** -- Check `~/.gradle/gradle.properties` has GitHub PAT with `read:packages` -- See `scripts/ci-prebuild.sh` for required format +- Check `~/.gradle/gradle.properties` has GitHub PAT with `read:packages`. **Ktlint formatting errors:** ```bash -./gradlew ktlintFormat # Auto-fix +./gradlew ktlintFormat ``` ## Module Discovery @@ -527,4 +594,4 @@ Modules are auto-discovered via `settings.gradle.kts`: - **Configuration cache** enabled (incubating) - **Type-safe project accessors** for faster builds - **Parallel builds** supported -- **Dependency analysis** plugin monitors dependency health \ No newline at end of file +- **Dependency analysis** plugin monitors dependency health From d090168b5089fa6aa10fb6f369df99f5ddfccbf8 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 9 Jun 2026 10:19:41 +0300 Subject: [PATCH 11/12] Make popBackstack automatically finish the app This avoids us forgetting to do this and either silently dropping the pop and having to manually do it on each call site --- CLAUDE.md | 4 +- .../com/hedvig/android/app/AndroidAppHost.kt | 53 + .../com/hedvig/android/app/MainActivity.kt | 46 +- .../app/navigation/BackstackController.kt | 22 +- .../app/navigation/HedvigEntryProvider.kt | 20 - .../com/hedvig/android/app/ui/HedvigApp.kt | 7 +- .../app/navigation/BackstackControllerTest.kt | 13 +- .../navigation/AddonPurchaseEntries.kt | 24 +- .../chip/id/navigation/ChipIdEntries.kt | 3 +- .../tier/navigation/ChooseTierEntries.kt | 1 - .../feature/claim/chat/ClaimChatEntries.kt | 1 - .../ConnectTrustlyPaymentEntries.kt | 1 - .../deleteaccount/DeleteAccountPresenter.kt | 1 - .../navigation/DeleteAccountEntries.kt | 1 - .../navigation/EditCoInsuredEntries.kt | 1 - .../feature/help/center/HelpCenterEntries.kt | 1 - .../home/home/navigation/HomeEntries.kt | 1 - .../insurances/navigation/InsuranceEntries.kt | 1 - .../feature/movingflow/MovingFlowEntries.kt | 1 - .../feature/profile/tab/ProfileEntries.kt | 3 +- .../remove/addons/RemoveAddonsEntries.kt | 1 - .../navigation/TerminateInsuranceEntries.kt | 9 +- .../android/navigation/compose/Backstack.kt | 22 +- ...ckstackcontroller-split-and-nav-markers.md | 1391 ----------------- 24 files changed, 119 insertions(+), 1509 deletions(-) delete mode 100644 docs/superpowers/plans/2026-06-04-appstate-backstackcontroller-split-and-nav-markers.md diff --git a/CLAUDE.md b/CLAUDE.md index 8ad71441a8..d9b1a30ed3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -137,7 +137,7 @@ Metro is a **compile-time** DI framework. There is a single graph, `AppScope`, f **Core annotations you will actually use:** - `@Inject` — constructor (or, on `MainActivity`/Application/Service, field) injection. -- `@SingleIn(AppScope::class)` — a singleton within the app graph. Apply to anything that must have exactly one instance (stateful services, caches, the back stack controller). +- `@SingleIn(AppScope::class)` — a singleton within the app graph. Apply to anything that must have exactly one instance (stateful services, caches, the back stack controller). Put it *wherever the binding is declared*: on the `@Inject` constructor (e.g. `SessionReconciler`), or on the `@Provides` method when you can't annotate the constructor (e.g. `BackstackController`, whose constructor takes hand-built snapshot holders — it's provided via `BackstackControllerProviders`). - `@ContributesBinding(AppScope::class)` — on an implementation class, binds it to its interface in the graph. The standard way to provide an `Impl` for an interface. - `@Provides` inside a `@ContributesTo(AppScope::class) interface` — for bindings you can't annotate a constructor on (framework types, builders, things needing configuration). See `ApplicationMetroProviders`. - `@ContributesIntoSet` / `@ContributesIntoMap` — multibindings. Used for sets of `SerializersModule`, deep-link matcher providers, notification senders, and the ViewModel/worker maps. @@ -229,7 +229,7 @@ Keys reachable cross-feature live in the feature's `-navigation` module and are ```kotlin backstack.add(ChatKey(id)) // push -backstack.popBackstack() // pop one +backstack.popBackstack() // pop one; at the root it finishes the app (Back/close exits) backstack.popUpTo(inclusive = true) backstack.navigateAndPopUpTo(BarKey, inclusive = true) backstack.navigateUp() // task-aware up (deep links) diff --git a/app/app/src/main/kotlin/com/hedvig/android/app/AndroidAppHost.kt b/app/app/src/main/kotlin/com/hedvig/android/app/AndroidAppHost.kt index 76742d731a..c044026719 100644 --- a/app/app/src/main/kotlin/com/hedvig/android/app/AndroidAppHost.kt +++ b/app/app/src/main/kotlin/com/hedvig/android/app/AndroidAppHost.kt @@ -1,6 +1,13 @@ package com.hedvig.android.app +import android.app.Activity +import androidx.activity.ComponentActivity import androidx.activity.SystemBarStyle +import androidx.activity.enableEdgeToEdge +import com.google.android.play.core.review.ReviewException +import com.google.android.play.core.review.ReviewManagerFactory +import com.hedvig.android.logger.LogPriority +import com.hedvig.android.logger.logcat /** * The Activity-bound capabilities the Compose app shell needs from its host. Implemented by @@ -16,3 +23,49 @@ internal interface AndroidAppHost { fun tryShowAppStoreReviewDialog() } + +internal class AndroidAppHostImpl(private val activity: ComponentActivity): AndroidAppHost { + override fun finishApp() = activity.finish() + + override fun applyEdgeToEdgeStyle(systemBarStyle: SystemBarStyle) { + activity.enableEdgeToEdge( + statusBarStyle = systemBarStyle, + navigationBarStyle = systemBarStyle, + ) + } + + override fun shouldShowPermissionRationale(permission: String): Boolean = + activity.shouldShowRequestPermissionRationale(permission) + + override fun tryShowAppStoreReviewDialog() = activity.tryShowPlayStoreReviewDialog() +} + +private fun Activity.tryShowPlayStoreReviewDialog() { + val tag = "PlayStoreReview" + val manager = ReviewManagerFactory.create(this) + logcat(LogPriority.INFO) { "$tag: requestReviewFlow" } + manager.requestReviewFlow().apply { + addOnFailureListener { logcat(LogPriority.INFO, it) { "$tag: requestReviewFlow failed:${it.message}" } } + addOnCanceledListener { logcat(LogPriority.INFO) { "$tag: requestReviewFlow cancelled" } } + addOnCompleteListener { task -> + if (task.isSuccessful) { + logcat(LogPriority.INFO) { "$tag: requestReviewFlow completed" } + val reviewInfo = task.result + logcat(LogPriority.INFO) { "$tag: launchReviewFlow with ReviewInfo:$reviewInfo" } + manager.launchReviewFlow(this@tryShowPlayStoreReviewDialog, reviewInfo).apply { + addOnFailureListener { logcat(LogPriority.INFO, it) { "$tag: launchReviewFlow failed:${it.message}" } } + addOnCanceledListener { logcat(LogPriority.INFO) { "$tag: launchReviewFlow canceled" } } + addOnCompleteListener { logcat(LogPriority.INFO) { "$tag: launchReviewFlow completed" } } + } + } else { + val exception = task.exception + val errorMessage = if (exception != null && exception is ReviewException) { + "ReviewException:${exception.message}. ReviewException::errorCode:${exception.errorCode}" + } else { + "Unknown error with message: ${exception?.message}" + } + logcat(LogPriority.INFO, exception) { "$tag: requestReviewFlow failed. Error:$errorMessage" } + } + } + } +} diff --git a/app/app/src/main/kotlin/com/hedvig/android/app/MainActivity.kt b/app/app/src/main/kotlin/com/hedvig/android/app/MainActivity.kt index 96bb562161..517c56db71 100644 --- a/app/app/src/main/kotlin/com/hedvig/android/app/MainActivity.kt +++ b/app/app/src/main/kotlin/com/hedvig/android/app/MainActivity.kt @@ -165,21 +165,7 @@ class MainActivity : AppCompatActivity() { addOnNewIntentListener { newIntent -> handleDeepLinkIntent(newIntent) } val externalNavigator = ExternalNavigatorImpl(this, hedvigBuildConstants.appPackageId) - val androidAppHost = object : AndroidAppHost { - override fun finishApp() = finish() - - override fun applyEdgeToEdgeStyle(systemBarStyle: SystemBarStyle) { - enableEdgeToEdge( - statusBarStyle = systemBarStyle, - navigationBarStyle = systemBarStyle, - ) - } - - override fun shouldShowPermissionRationale(permission: String): Boolean = - shouldShowRequestPermissionRationale(permission) - - override fun tryShowAppStoreReviewDialog() = tryShowPlayStoreReviewDialog() - } + val androidAppHost = AndroidAppHostImpl(this) RiveInitializer.init(this) // Attach the Activity-bound task hooks to the app-scoped controller. Done here (not at // construction) and re-attached on every recreation: the singleton outlives any single Activity, @@ -189,6 +175,7 @@ class MainActivity : AppCompatActivity() { backstackController.escapeToOwnTask = { parentStack -> NavigationStateBridge.escapeToOwnTask(this@MainActivity, parentStack, serializersModules) } + backstackController.finishApp = androidAppHost::finishApp NavigationStateBridge.restoreAndPersist( backstackController = backstackController, savedStateRegistry = savedStateRegistry, @@ -274,35 +261,6 @@ private fun applyTheme(theme: Theme?, uiModeManager: UiModeManager?) { } } -private fun Activity.tryShowPlayStoreReviewDialog() { - val tag = "PlayStoreReview" - val manager = ReviewManagerFactory.create(this) - logcat(LogPriority.INFO) { "$tag: requestReviewFlow" } - manager.requestReviewFlow().apply { - addOnFailureListener { logcat(LogPriority.INFO, it) { "$tag: requestReviewFlow failed:${it.message}" } } - addOnCanceledListener { logcat(LogPriority.INFO) { "$tag: requestReviewFlow cancelled" } } - addOnCompleteListener { task -> - if (task.isSuccessful) { - logcat(LogPriority.INFO) { "$tag: requestReviewFlow completed" } - val reviewInfo = task.result - logcat(LogPriority.INFO) { "$tag: launchReviewFlow with ReviewInfo:$reviewInfo" } - manager.launchReviewFlow(this@tryShowPlayStoreReviewDialog, reviewInfo).apply { - addOnFailureListener { logcat(LogPriority.INFO, it) { "$tag: launchReviewFlow failed:${it.message}" } } - addOnCanceledListener { logcat(LogPriority.INFO) { "$tag: launchReviewFlow canceled" } } - addOnCompleteListener { logcat(LogPriority.INFO) { "$tag: launchReviewFlow completed" } } - } - } else { - val exception = task.exception - val errorMessage = if (exception != null && exception is ReviewException) { - "ReviewException:${exception.message}. ReviewException::errorCode:${exception.errorCode}" - } else { - "Unknown error with message: ${exception?.message}" - } - logcat(LogPriority.INFO, exception) { "$tag: requestReviewFlow failed. Error:$errorMessage" } - } - } - } -} private fun getSystemLocale(config: android.content.res.Configuration): Locale { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { diff --git a/app/app/src/main/kotlin/com/hedvig/android/app/navigation/BackstackController.kt b/app/app/src/main/kotlin/com/hedvig/android/app/navigation/BackstackController.kt index 5aa5cf0740..2fec6c5dc4 100644 --- a/app/app/src/main/kotlin/com/hedvig/android/app/navigation/BackstackController.kt +++ b/app/app/src/main/kotlin/com/hedvig/android/app/navigation/BackstackController.kt @@ -20,7 +20,6 @@ import com.hedvig.android.navigation.common.StashedSession import com.hedvig.android.navigation.common.TopLevelTab import com.hedvig.android.navigation.compose.Backstack import com.hedvig.android.navigation.compose.LoneDeepLinkChrome -import com.hedvig.android.navigation.compose.popBackstack import dev.zacsweers.metro.ContributesTo import dev.zacsweers.metro.Provides import dev.zacsweers.metro.SingleIn @@ -66,6 +65,13 @@ internal class BackstackController( * the target stack. Attached/replaced by the Activity like [isOwnTask]; no-op by default. */ var escapeToOwnTask: (List) -> Unit = {}, + /** + * Finishes the host Activity, used by [popBackstack] when a Back/close lands on the root (nothing + * left to pop) so the app exits instead of stranding the user on a dead screen. Attached/replaced + * by the Activity like [isOwnTask] and [escapeToOwnTask]; no-op by default so unit tests and any + * pre-attach use never try to finish. + */ + var finishApp: () -> Unit = {}, ) : Backstack { /** * A deep link resolved while logged out, held until [setLoggedIn] consumes it (so it can land @@ -201,7 +207,19 @@ internal class BackstackController( } return true } - return popBackstack() + // Fall through to a *pure* pop (not our finishing override): an Up press at the root is a no-op, + // it must not exit the app the way Back does. + return super.popBackstack() + } + + /** + * Back/close pop. Pops the top entry; if there is nothing to pop (we are at the root) the Activity + * is finished so the app exits rather than leaving the user on a screen where Back does nothing. + */ + override fun popBackstack(): Boolean { + val popped = super.popBackstack() + if (!popped) finishApp() + return popped } /** diff --git a/app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigEntryProvider.kt b/app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigEntryProvider.kt index dd330b8b82..663844822e 100644 --- a/app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigEntryProvider.kt +++ b/app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigEntryProvider.kt @@ -64,7 +64,6 @@ import com.hedvig.android.navigation.common.TopLevelTab import com.hedvig.android.navigation.compose.add import com.hedvig.android.navigation.compose.findLastOrNull import com.hedvig.android.navigation.compose.navigateAndPopUpTo -import com.hedvig.android.navigation.compose.popBackstack import com.hedvig.android.navigation.compose.popUpTo import com.hedvig.android.navigation.compose.removeAllOf import com.hedvig.feature.claim.chat.ClaimChatKey @@ -107,13 +106,6 @@ internal fun EntryProviderScope.hedvigEntryProvider( val onNavigateToImageViewer: (String, String) -> Unit = { imageUrl, cacheKey -> backstack.add(ImageViewerKey(imageUrl, cacheKey)) } - val popBackstackOrFinish = { - if (!backstack.popBackstack()) { - finishApp() - } - Unit - } - addLoginEntries(backstack, hedvigBuildConstants, openUrl, externalNavigator, scope, memberIdService) addHomeEntries( backstack = backstack, @@ -156,7 +148,6 @@ internal fun EntryProviderScope.hedvigEntryProvider( languageService = languageService, externalNavigator = externalNavigator, openUrl = openUrl, - popBackstackOrFinish = popBackstackOrFinish, navigateToConnectPayment = navigateToConnectPayment, navigateToPayoutAccount = navigateToPayoutAccount, navigateToNewConversation = navigateToNewConversation, @@ -175,10 +166,7 @@ internal fun EntryProviderScope.hedvigEntryProvider( imageLoader = imageLoader, openUrl = openUrl, externalNavigator = externalNavigator, - finishApp = finishApp, - popBackstackOrFinish = popBackstackOrFinish, navigateToNewConversation = navigateToNewConversation, - navigateToMovingFlow = navigateToMovingFlow, navigateToInbox = navigateToInbox, ) } @@ -448,7 +436,6 @@ private fun EntryProviderScope.addProfileEntries( languageService: LanguageService, externalNavigator: ExternalNavigator, openUrl: (String) -> Unit, - popBackstackOrFinish: () -> Unit, navigateToConnectPayment: () -> Unit, navigateToPayoutAccount: () -> Unit, navigateToNewConversation: () -> Unit, @@ -465,7 +452,6 @@ private fun EntryProviderScope.addProfileEntries( }, globalSnackBarState = globalSnackBarState, backstack = backstack, - popBackstackOrFinish = popBackstackOrFinish, hedvigBuildConstants = hedvigBuildConstants, navigateToConnectPayment = navigateToConnectPayment, navigateToConnectPayout = navigateToPayoutAccount, @@ -519,16 +505,11 @@ private fun EntryProviderScope.addSharedFlowEntries( imageLoader: ImageLoader, openUrl: (String) -> Unit, externalNavigator: ExternalNavigator, - finishApp: () -> Unit, - popBackstackOrFinish: () -> Unit, navigateToNewConversation: () -> Unit, - navigateToMovingFlow: () -> Unit, navigateToInbox: () -> Unit, ) { addonPurchaseEntries( backstack = backstack, - popBackstack = popBackstackOrFinish, - finishApp = finishApp, onNavigateToNewConversation = navigateToNewConversation, onNavigateToChangeTier = { contractId -> backstack.add(StartTierFlowKey(insuranceId = contractId)) @@ -542,7 +523,6 @@ private fun EntryProviderScope.addSharedFlowEntries( backstack = backstack, globalSnackBarState = globalSnackBarState, navigateUp = backstack::navigateUp, - popBackstackOrFinish = popBackstackOrFinish, goHome = { backstack.popUpTo(inclusive = true) backstack.selectTopLevel(TopLevelTab.Home) diff --git a/app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigApp.kt b/app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigApp.kt index 839beb7ddf..32fc094263 100644 --- a/app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigApp.kt +++ b/app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigApp.kt @@ -73,7 +73,6 @@ import com.hedvig.android.navigation.activity.ExternalNavigator import com.hedvig.android.navigation.common.HedvigNavKey import com.hedvig.android.navigation.compose.HedvigDeepLinkMatcher import com.hedvig.android.navigation.compose.entryDecorators -import com.hedvig.android.navigation.compose.popBackstack import com.hedvig.android.notification.badge.data.payment.MissedPaymentNotificationService import com.hedvig.android.ui.force.upgrade.ForceUpgradeBlockingScreen import hedvig.resources.EXIT_DEMO_MODE_BUTTON @@ -181,11 +180,7 @@ internal fun HedvigApp( GlobalHedvigSnackBar(globalSnackBarState = globalSnackBarState) NavDisplay( backStack = backstackController.entries, - onBack = { - if (!backstackController.popBackstack()) { - androidAppHost.finishApp() - } - }, + onBack = backstackController::popBackstack, entryDecorators = entryDecorators { backstackController.allLiveContentKeys }, sharedTransitionScope = this@SharedTransitionLayout, sceneDecoratorStrategies = sceneDecoratorStrategies, diff --git a/app/app/src/test/kotlin/com/hedvig/android/app/navigation/BackstackControllerTest.kt b/app/app/src/test/kotlin/com/hedvig/android/app/navigation/BackstackControllerTest.kt index 8cd49cec88..c2e0e9f89a 100644 --- a/app/app/src/test/kotlin/com/hedvig/android/app/navigation/BackstackControllerTest.kt +++ b/app/app/src/test/kotlin/com/hedvig/android/app/navigation/BackstackControllerTest.kt @@ -19,7 +19,6 @@ import com.hedvig.android.feature.profile.navigation.ProfileKey import com.hedvig.android.navigation.common.HedvigNavKey import com.hedvig.android.navigation.common.TopLevelTab import com.hedvig.android.navigation.compose.LoneDeepLinkChrome -import com.hedvig.android.navigation.compose.popBackstack import org.junit.Test internal class BackstackControllerTest { @@ -68,9 +67,17 @@ internal class BackstackControllerTest { } @Test - fun `system-back at the Home root exits the app`() { - val controller = controllerWith(HomeKey) + fun `system-back at the Home root finishes the app and keeps the root`() { + var finished = false + val controller = BackstackController( + mutableStateListOf(HomeKey), + mutableStateMapOf(), + mutableStateOf(null), + mutableStateOf(null), + finishApp = { finished = true }, + ) assertThat(controller.popBackstack()).isFalse() + assertThat(finished).isTrue() assertThat(controller.entries.toList()).containsExactly(HomeKey) } diff --git a/app/feature/feature-addon-purchase/src/main/kotlin/com/hedvig/android/feature/addon/purchase/navigation/AddonPurchaseEntries.kt b/app/feature/feature-addon-purchase/src/main/kotlin/com/hedvig/android/feature/addon/purchase/navigation/AddonPurchaseEntries.kt index 15dd3d907a..dcc9138a72 100644 --- a/app/feature/feature-addon-purchase/src/main/kotlin/com/hedvig/android/feature/addon/purchase/navigation/AddonPurchaseEntries.kt +++ b/app/feature/feature-addon-purchase/src/main/kotlin/com/hedvig/android/feature/addon/purchase/navigation/AddonPurchaseEntries.kt @@ -21,7 +21,6 @@ import com.hedvig.android.navigation.compose.Backstack import com.hedvig.android.navigation.compose.add import com.hedvig.android.navigation.compose.findLastOrNull import com.hedvig.android.navigation.compose.navigateAndPopUpTo -import com.hedvig.android.navigation.compose.popBackstack import com.hedvig.android.navigation.compose.popUpTo import dev.zacsweers.metrox.viewmodel.assistedMetroViewModel import kotlinx.serialization.Serializable @@ -35,8 +34,6 @@ internal data class PerilComparisonParams( fun EntryProviderScope.addonPurchaseEntries( backstack: Backstack, - popBackstack: () -> Unit, - finishApp: () -> Unit, onNavigateToNewConversation: () -> Unit, onNavigateToChangeTier: (contractId: String) -> Unit, ) { @@ -89,8 +86,7 @@ fun EntryProviderScope.addonPurchaseEntries( backstack = backstack, insuranceId = insuranceIds[0], preselectedAddonDisplayNames = preselectedAddonDisplayNames, - popBackstack = popBackstack, - finishApp = finishApp, + popBackstack = backstack::popBackstack, onNavigateToChangeTier = onNavigateToChangeTier, ) } else { @@ -101,7 +97,7 @@ fun EntryProviderScope.addonPurchaseEntries( SelectInsuranceForAddonDestination( viewModel = viewModel, navigateUp = backstack::navigateUp, - popBackstack = popBackstack, + popBackstack = backstack::popBackstack, ) } } @@ -111,8 +107,7 @@ fun EntryProviderScope.addonPurchaseEntries( backstack = backstack, insuranceId = key.insuranceId, preselectedAddonDisplayNames = key.preselectedAddonDisplayNames, - popBackstack = popBackstack, - finishApp = finishApp, + popBackstack = backstack::popBackstack, onNavigateToChangeTier = onNavigateToChangeTier, ) } @@ -135,20 +130,20 @@ fun EntryProviderScope.addonPurchaseEntries( AddonSummaryDestination( viewModel = viewModel, navigateUp = backstack::navigateUp, - navigateBack = popBackstack, + navigateBack = backstack::popBackstack, ) } entry { SubmitAddonFailureScreen( - popBackstack = popBackstack, + popBackstack = backstack::popBackstack, ) } entry { key -> SubmitAddonSuccessScreen( activationDate = key.activationDate, - popBackstack = popBackstack, + popBackstack = backstack::popBackstack, ) } } @@ -159,7 +154,6 @@ private fun CustomizeAddonContent( insuranceId: String, preselectedAddonDisplayNames: List, popBackstack: () -> Unit, - finishApp: () -> Unit, onNavigateToChangeTier: (contractId: String) -> Unit, ) { val viewModel: CustomizeAddonViewModel = @@ -171,12 +165,8 @@ private fun CustomizeAddonContent( navigateUp = backstack::navigateUp, popBackstack = popBackstack, popAddonFlow = { - // Drop everything above the anchor, then the anchor itself; if it was the root there is - // nothing left to show, so finish. backstack.popUpTo(inclusive = false) - if (!backstack.popBackstack()) { - finishApp() - } + backstack.popBackstack() }, onNavigateToTravelInsurancePlusExplanation = { perilData -> backstack.add(TravelInsurancePlusExplanationKey(perilData)) diff --git a/app/feature/feature-chip-id/src/main/kotlin/com/hedvig/android/feature/chip/id/navigation/ChipIdEntries.kt b/app/feature/feature-chip-id/src/main/kotlin/com/hedvig/android/feature/chip/id/navigation/ChipIdEntries.kt index 73df9e6ac6..7ff21c0c2d 100644 --- a/app/feature/feature-chip-id/src/main/kotlin/com/hedvig/android/feature/chip/id/navigation/ChipIdEntries.kt +++ b/app/feature/feature-chip-id/src/main/kotlin/com/hedvig/android/feature/chip/id/navigation/ChipIdEntries.kt @@ -18,7 +18,6 @@ fun EntryProviderScope.chipIdEntries( backstack: Backstack, globalSnackBarState: GlobalSnackBarState, navigateUp: () -> Unit, - popBackstackOrFinish: () -> Unit, goHome: () -> Unit, ) { entry { key -> @@ -40,7 +39,7 @@ fun EntryProviderScope.chipIdEntries( SelectInsuranceForChipIdDestination( viewModel = viewModel, navigateUp = navigateUp, - popBackstack = popBackstackOrFinish, + popBackstack = backstack::popBackstack, ) } diff --git a/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/navigation/ChooseTierEntries.kt b/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/navigation/ChooseTierEntries.kt index 4458d77873..a1015ffb64 100644 --- a/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/navigation/ChooseTierEntries.kt +++ b/app/feature/feature-choose-tier/src/main/kotlin/com/hedvig/android/feature/change/tier/navigation/ChooseTierEntries.kt @@ -14,7 +14,6 @@ import com.hedvig.android.feature.change.tier.ui.stepsummary.SubmitTierSuccessSc import com.hedvig.android.feature.change.tier.ui.stepsummary.SummaryViewModel import com.hedvig.android.navigation.common.HedvigNavKey import com.hedvig.android.navigation.compose.Backstack -import com.hedvig.android.navigation.compose.popBackstack import com.hedvig.android.navigation.compose.popUpTo import com.hedvig.android.shared.tier.comparison.ui.ComparisonDestination import com.hedvig.android.shared.tier.comparison.ui.ComparisonViewModel diff --git a/app/feature/feature-claim-chat/src/androidMain/kotlin/com/hedvig/feature/claim/chat/ClaimChatEntries.kt b/app/feature/feature-claim-chat/src/androidMain/kotlin/com/hedvig/feature/claim/chat/ClaimChatEntries.kt index 184e9f1b52..372d695f78 100644 --- a/app/feature/feature-claim-chat/src/androidMain/kotlin/com/hedvig/feature/claim/chat/ClaimChatEntries.kt +++ b/app/feature/feature-claim-chat/src/androidMain/kotlin/com/hedvig/feature/claim/chat/ClaimChatEntries.kt @@ -8,7 +8,6 @@ import com.hedvig.android.navigation.common.HedvigNavKey import com.hedvig.android.navigation.compose.Backstack import com.hedvig.android.navigation.compose.add import com.hedvig.android.navigation.compose.navigateAndPopUpTo -import com.hedvig.android.navigation.compose.popBackstack import com.hedvig.android.ui.force.upgrade.ForceUpgradeBlockingScreen import com.hedvig.feature.claim.chat.data.ClaimIntentOutcome import com.hedvig.feature.claim.chat.data.StepContent diff --git a/app/feature/feature-connect-payment-trustly/src/main/kotlin/com/hedvig/android/feature/connect/payment/trustly/navigation/ConnectTrustlyPaymentEntries.kt b/app/feature/feature-connect-payment-trustly/src/main/kotlin/com/hedvig/android/feature/connect/payment/trustly/navigation/ConnectTrustlyPaymentEntries.kt index 72c58dd3f1..7910944390 100644 --- a/app/feature/feature-connect-payment-trustly/src/main/kotlin/com/hedvig/android/feature/connect/payment/trustly/navigation/ConnectTrustlyPaymentEntries.kt +++ b/app/feature/feature-connect-payment-trustly/src/main/kotlin/com/hedvig/android/feature/connect/payment/trustly/navigation/ConnectTrustlyPaymentEntries.kt @@ -6,7 +6,6 @@ import com.hedvig.android.feature.connect.payment.trustly.ui.TrustlyDestination import com.hedvig.android.feature.connect.payment.trustly.ui.TrustlyKey import com.hedvig.android.navigation.common.HedvigNavKey import com.hedvig.android.navigation.compose.Backstack -import com.hedvig.android.navigation.compose.popBackstack import dev.zacsweers.metrox.viewmodel.metroViewModel fun EntryProviderScope.connectPaymentEntries(backstack: Backstack) { diff --git a/app/feature/feature-delete-account/src/main/kotlin/com/hedvig/android/feature/deleteaccount/DeleteAccountPresenter.kt b/app/feature/feature-delete-account/src/main/kotlin/com/hedvig/android/feature/deleteaccount/DeleteAccountPresenter.kt index 4e83d8ba52..3ab59ece66 100644 --- a/app/feature/feature-delete-account/src/main/kotlin/com/hedvig/android/feature/deleteaccount/DeleteAccountPresenter.kt +++ b/app/feature/feature-delete-account/src/main/kotlin/com/hedvig/android/feature/deleteaccount/DeleteAccountPresenter.kt @@ -15,7 +15,6 @@ import com.hedvig.android.feature.deleteaccount.data.RequestAccountDeletionUseCa import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.navigation.compose.Backstack -import com.hedvig.android.navigation.compose.popBackstack import kotlinx.coroutines.flow.collectLatest internal class DeleteAccountPresenter( diff --git a/app/feature/feature-delete-account/src/main/kotlin/com/hedvig/android/feature/deleteaccount/navigation/DeleteAccountEntries.kt b/app/feature/feature-delete-account/src/main/kotlin/com/hedvig/android/feature/deleteaccount/navigation/DeleteAccountEntries.kt index 5aab7dd325..3906d5f83e 100644 --- a/app/feature/feature-delete-account/src/main/kotlin/com/hedvig/android/feature/deleteaccount/navigation/DeleteAccountEntries.kt +++ b/app/feature/feature-delete-account/src/main/kotlin/com/hedvig/android/feature/deleteaccount/navigation/DeleteAccountEntries.kt @@ -5,7 +5,6 @@ import com.hedvig.android.feature.chat.DeleteAccountViewModel import com.hedvig.android.feature.deleteaccount.DeleteAccountDestination import com.hedvig.android.navigation.common.HedvigNavKey import com.hedvig.android.navigation.compose.Backstack -import com.hedvig.android.navigation.compose.popBackstack import dev.zacsweers.metrox.viewmodel.metroViewModel fun EntryProviderScope.deleteAccountEntries(backstack: Backstack) { diff --git a/app/feature/feature-edit-coinsured/src/main/kotlin/com/hedvig/android/feature/editcoinsured/navigation/EditCoInsuredEntries.kt b/app/feature/feature-edit-coinsured/src/main/kotlin/com/hedvig/android/feature/editcoinsured/navigation/EditCoInsuredEntries.kt index 97c6b1cc43..d49e221530 100644 --- a/app/feature/feature-edit-coinsured/src/main/kotlin/com/hedvig/android/feature/editcoinsured/navigation/EditCoInsuredEntries.kt +++ b/app/feature/feature-edit-coinsured/src/main/kotlin/com/hedvig/android/feature/editcoinsured/navigation/EditCoInsuredEntries.kt @@ -10,7 +10,6 @@ import com.hedvig.android.feature.editcoinsured.ui.triage.EditCoInsuredTriageDes import com.hedvig.android.feature.editcoinsured.ui.triage.EditCoInsuredTriageViewModel import com.hedvig.android.navigation.common.HedvigNavKey import com.hedvig.android.navigation.compose.Backstack -import com.hedvig.android.navigation.compose.popBackstack import dev.zacsweers.metrox.viewmodel.assistedMetroViewModel fun EntryProviderScope.editCoInsuredEntries(backstack: Backstack) { diff --git a/app/feature/feature-help-center/src/commonMain/kotlin/com/hedvig/android/feature/help/center/HelpCenterEntries.kt b/app/feature/feature-help-center/src/commonMain/kotlin/com/hedvig/android/feature/help/center/HelpCenterEntries.kt index 36f3b63185..6a1bbd0b11 100644 --- a/app/feature/feature-help-center/src/commonMain/kotlin/com/hedvig/android/feature/help/center/HelpCenterEntries.kt +++ b/app/feature/feature-help-center/src/commonMain/kotlin/com/hedvig/android/feature/help/center/HelpCenterEntries.kt @@ -25,7 +25,6 @@ import com.hedvig.android.feature.help.center.topic.HelpCenterTopicViewModel import com.hedvig.android.navigation.common.HedvigNavKey import com.hedvig.android.navigation.compose.Backstack import com.hedvig.android.navigation.compose.add -import com.hedvig.android.navigation.compose.popBackstack import dev.zacsweers.metrox.viewmodel.assistedMetroViewModel import dev.zacsweers.metrox.viewmodel.metroViewModel diff --git a/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/navigation/HomeEntries.kt b/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/navigation/HomeEntries.kt index 703e6ab8f0..1378a541b8 100644 --- a/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/navigation/HomeEntries.kt +++ b/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/navigation/HomeEntries.kt @@ -12,7 +12,6 @@ import com.hedvig.android.navigation.common.HedvigNavKey import com.hedvig.android.navigation.compose.Backstack import com.hedvig.android.navigation.compose.NavSuiteSceneDecoratorStrategy import com.hedvig.android.navigation.compose.add -import com.hedvig.android.navigation.compose.popBackstack import dev.zacsweers.metrox.viewmodel.metroViewModel fun EntryProviderScope.homeEntries( diff --git a/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/navigation/InsuranceEntries.kt b/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/navigation/InsuranceEntries.kt index f211712073..8c3e7b4241 100644 --- a/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/navigation/InsuranceEntries.kt +++ b/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/navigation/InsuranceEntries.kt @@ -18,7 +18,6 @@ import com.hedvig.android.navigation.common.HedvigNavKey import com.hedvig.android.navigation.compose.Backstack import com.hedvig.android.navigation.compose.NavSuiteSceneDecoratorStrategy import com.hedvig.android.navigation.compose.add -import com.hedvig.android.navigation.compose.popBackstack import dev.zacsweers.metrox.viewmodel.assistedMetroViewModel import dev.zacsweers.metrox.viewmodel.metroViewModel diff --git a/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/MovingFlowEntries.kt b/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/MovingFlowEntries.kt index 74f4cff93b..23526ab193 100644 --- a/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/MovingFlowEntries.kt +++ b/app/feature/feature-movingflow/src/main/kotlin/com/hedvig/android/feature/movingflow/MovingFlowEntries.kt @@ -16,7 +16,6 @@ import com.hedvig.android.feature.movingflow.ui.summary.SummaryDestination import com.hedvig.android.feature.movingflow.ui.summary.SummaryViewModel import com.hedvig.android.navigation.common.HedvigNavKey import com.hedvig.android.navigation.compose.Backstack -import com.hedvig.android.navigation.compose.popBackstack import com.hedvig.android.navigation.compose.popUpTo import com.hedvig.android.shared.tier.comparison.navigation.ComparisonParameters import com.hedvig.android.shared.tier.comparison.ui.ComparisonDestination diff --git a/app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/tab/ProfileEntries.kt b/app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/tab/ProfileEntries.kt index ded2664d3b..450149ec69 100644 --- a/app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/tab/ProfileEntries.kt +++ b/app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/tab/ProfileEntries.kt @@ -36,7 +36,6 @@ fun EntryProviderScope.profileEntries( nestedEntries: EntryProviderScope.() -> Unit, globalSnackBarState: GlobalSnackBarState, backstack: Backstack, - popBackstackOrFinish: () -> Unit, hedvigBuildConstants: HedvigBuildConstants, navigateToConnectPayment: () -> Unit, navigateToConnectPayout: () -> Unit, @@ -98,7 +97,7 @@ fun EntryProviderScope.profileEntries( viewModel = viewModel, globalSnackBarState = globalSnackBarState, navigateUp = backstack::navigateUp, - popBackstack = popBackstackOrFinish, + popBackstack = backstack::popBackstack, ) } entry { diff --git a/app/feature/feature-remove-addons/src/androidMain/kotlin/com/hedvig/feature/remove/addons/RemoveAddonsEntries.kt b/app/feature/feature-remove-addons/src/androidMain/kotlin/com/hedvig/feature/remove/addons/RemoveAddonsEntries.kt index f23ed0453d..e2a0a439f6 100644 --- a/app/feature/feature-remove-addons/src/androidMain/kotlin/com/hedvig/feature/remove/addons/RemoveAddonsEntries.kt +++ b/app/feature/feature-remove-addons/src/androidMain/kotlin/com/hedvig/feature/remove/addons/RemoveAddonsEntries.kt @@ -5,7 +5,6 @@ import androidx.navigation3.runtime.EntryProviderScope import com.hedvig.android.navigation.common.HedvigNavKey import com.hedvig.android.navigation.compose.Backstack import com.hedvig.android.navigation.compose.navigateAndPopUpTo -import com.hedvig.android.navigation.compose.popBackstack import com.hedvig.feature.remove.addons.ui.RemoveAddonFailureScreen import com.hedvig.feature.remove.addons.ui.RemoveAddonSuccessScreen import com.hedvig.feature.remove.addons.ui.RemoveAddonSummaryDestination diff --git a/app/feature/feature-terminate-insurance/src/main/kotlin/com/hedvig/android/feature/terminateinsurance/navigation/TerminateInsuranceEntries.kt b/app/feature/feature-terminate-insurance/src/main/kotlin/com/hedvig/android/feature/terminateinsurance/navigation/TerminateInsuranceEntries.kt index bc12af2803..fe3e1f69ee 100644 --- a/app/feature/feature-terminate-insurance/src/main/kotlin/com/hedvig/android/feature/terminateinsurance/navigation/TerminateInsuranceEntries.kt +++ b/app/feature/feature-terminate-insurance/src/main/kotlin/com/hedvig/android/feature/terminateinsurance/navigation/TerminateInsuranceEntries.kt @@ -21,9 +21,10 @@ import com.hedvig.android.feature.terminateinsurance.step.terminationreview.Term import com.hedvig.android.feature.terminateinsurance.step.terminationsuccess.TerminationSuccessDestination import com.hedvig.android.feature.terminateinsurance.step.unknown.UnknownScreenDestination import com.hedvig.android.navigation.common.HedvigNavKey +import com.hedvig.android.navigation.common.TopLevelTab import com.hedvig.android.navigation.compose.Backstack import com.hedvig.android.navigation.compose.add -import com.hedvig.android.navigation.compose.popBackstack +import com.hedvig.android.navigation.compose.popUpTo import dev.zacsweers.metrox.viewmodel.assistedMetroViewModel fun EntryProviderScope.terminateInsuranceEntries( @@ -59,7 +60,11 @@ fun EntryProviderScope.terminateInsuranceEntries( TerminationSuccessDestination( terminationDate = key.terminationDate, onDone = { - if (!backstack.popBackstack()) { + // Reached alone via deep link → land on the Insurances tab rather than exiting the app + // (popBackstack would finish the Activity at the root). + if (backstack.entries.size > 1) { + backstack.popBackstack() + } else { navigateToInsurances() } }, diff --git a/app/navigation/navigation-compose/src/commonMain/kotlin/com/hedvig/android/navigation/compose/Backstack.kt b/app/navigation/navigation-compose/src/commonMain/kotlin/com/hedvig/android/navigation/compose/Backstack.kt index 96e9f50a92..01734dae5f 100644 --- a/app/navigation/navigation-compose/src/commonMain/kotlin/com/hedvig/android/navigation/compose/Backstack.kt +++ b/app/navigation/navigation-compose/src/commonMain/kotlin/com/hedvig/android/navigation/compose/Backstack.kt @@ -19,18 +19,26 @@ interface Backstack { * task-aware synthetic-stack rebuilding for lone deep links (see BackstackController). */ fun navigateUp(): Boolean = popBackstack() + + /** + * Pops the top entry. Returns false when the back stack is already at its root (nothing popped). + * + * This default is a pure temporal pop. `:app`'s controller overrides it so that a pop at the root + * finishes the Activity instead of silently no-opping — i.e. Back/close from the last screen exits + * the app. A caller that needs a different at-root behavior (e.g. jump to another tab) must branch + * on the back stack itself rather than on this return value, since in `:app` the root case never + * returns: it finishes. + */ + fun popBackstack(): Boolean = Snapshot.withMutableSnapshot { + if (entries.size <= 1) return@withMutableSnapshot false + entries.removeAt(entries.lastIndex) + true + } } /** Pushes [key] onto the top of the back stack. */ fun Backstack.add(key: HedvigNavKey): Boolean = entries.add(key) -/** Pops the top entry. Returns false if the back stack is at its root (nothing popped). */ -fun Backstack.popBackstack(): Boolean = Snapshot.withMutableSnapshot { - if (entries.size <= 1) return@withMutableSnapshot false - entries.removeAt(entries.lastIndex) - true -} - /** Pops up to (and optionally including) the most recent entry of [T]. No-op if absent. */ inline fun Backstack.popUpTo(inclusive: Boolean) { val index = entries.indexOfLast { it is T } diff --git a/docs/superpowers/plans/2026-06-04-appstate-backstackcontroller-split-and-nav-markers.md b/docs/superpowers/plans/2026-06-04-appstate-backstackcontroller-split-and-nav-markers.md deleted file mode 100644 index 68a069dec7..0000000000 --- a/docs/superpowers/plans/2026-06-04-appstate-backstackcontroller-split-and-nav-markers.md +++ /dev/null @@ -1,1391 +0,0 @@ -# HedvigAppState / BackstackController Split & Navigation Capability Markers Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Clean up the messy seam between `HedvigAppState` (app-shell state, feature-aware) and `BackstackController` (pure navigation state machine), replace three centralized destination-classification lists with per-key marker interfaces, reinstate the lost "deliberate logout from Profile discards session" behavior, and replace the global-mutable `CurrentDestinationInMemoryStorage` with a DI-injected `StateFlow`-backed holder. - -**Architecture:** `BackstackController` owns all pure-navigation reads/writes (entries, parked runs, logged-in detection, top-level selection, login/logout transitions). `HedvigAppState` keeps only feature-knowledge-layered state (navigation suite type, top-level graph set, payments badge, force-update gate, theme, cross-sell eligibility) and stops re-exposing controller methods as forwarders. Destination classification (cross-sell eligibility, chat-notification suppression, deliberate-logout origin) moves from centralized `List>` registries into marker interfaces declared in `navigation-common` (the one module every `HedvigNavKey` already depends on — features can't depend on features). The current destination is published through a `@SingleIn(AppScope::class)` `CurrentDestinationHolder` exposing a `StateFlow`, written by a dedicated `ReportCurrentDestinationEffect` and read by `ChatNotificationSender` via constructor injection. - -**Tech Stack:** Kotlin, Jetpack Navigation 3, Jetpack Compose (Snapshot state), Metro DI (`@SingleIn`, `@Inject`, `@Provides`, `@IntoSet`), Kotlin Multiplatform (`navigation-common` commonMain), kotlinx.coroutines `StateFlow`, assertk + JUnit. - ---- - -## File Structure - -**Created:** -- `app/app/src/main/kotlin/com/hedvig/android/app/navigation/CurrentDestinationHolder.kt` — `@SingleIn(AppScope::class)` holder exposing `StateFlow`; the single source of "what is on top right now" for non-Composable consumers. - -**Modified:** -- `app/navigation/navigation-common/src/commonMain/kotlin/com/hedvig/android/navigation/common/HedvigNavKey.kt` — add three marker interfaces. -- `app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/navigation/HomeDestinations.kt` — `HomeKey` implements two markers; delete `homeCrossSellBottomSheetPermittingDestinations`. -- `app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/navigation/InsurancesNavigation.kt` — `InsurancesKey` + `InsuranceContractDetailKey` implement cross-sell marker; delete `insurancesCrossSellBottomSheetPermittingDestinations`. -- `app/feature/feature-travel-certificate/src/main/kotlin/com/hedvig/android/feature/travelcertificate/navigation/TravelCertificateDestination.kt` — `TravelCertificateKey` implements cross-sell marker; delete `travelCertificateCrossSellBottomSheetPermittingDestinations`. -- `app/feature/feature-help-center/src/commonMain/kotlin/com/hedvig/android/feature/help/center/navigation/HelpCenterDestination.kt` — `HelpCenterKey` + `HelpCenterTopicKey` + `HelpCenterQuestionKey` implement cross-sell marker; delete `helpCenterCrossSellBottomSheetPermittingDestinations`. -- `app/feature/feature-chat/src/main/kotlin/com/hedvig/android/feature/chat/navigation/ChatDestination.kt` — `ChatKey` + `InboxKey` implement chat-suppression marker. -- `app/feature/feature-claim-details/src/main/kotlin/com/hedvig/android/feature/claim/details/navigation/ClaimDetailDestinations.kt` — `ClaimDetailsKey` implements chat-suppression marker. -- `app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/navigation/ProfileDestinations.kt` — `ProfileKey` implements deliberate-logout marker; delete dead `destinationToExcludeFromSavingState`. -- `app/app/src/main/kotlin/com/hedvig/android/app/notification/senders/ChatNotificationSender.kt` — use marker check; remove `CurrentDestinationInMemoryStorage`; read injected holder. -- `app/app/src/main/kotlin/com/hedvig/android/app/di/ApplicationMetroProviders.kt` — inject `CurrentDestinationHolder` into `provideChatNotificationSender`. -- `app/app/src/main/kotlin/com/hedvig/android/app/navigation/BackstackController.kt` — `setLoggedOut` honors `DeliberateLogoutOrigin`. -- `app/app/src/test/kotlin/com/hedvig/android/app/navigation/BackstackControllerTest.kt` — adjust stash test; add deliberate-logout test. -- `app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigAppState.kt` — cross-sell marker check; remove forwarders; remove the destination-mirroring `LaunchedEffect`. -- `app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigNavHost.kt` — drop `hedvigAppState` param; take `backstackController` + `windowSizeClass` directly. -- `app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigAppUi.kt` — pass `backstackController` + `windowSizeClass` to `HedvigNavHost`; read top-level via controller. -- `app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigApp.kt` — effects call controller directly; add `ReportCurrentDestinationEffect`; thread `currentDestinationHolder`. -- `app/app/src/main/kotlin/com/hedvig/android/app/MainActivity.kt` — inject `CurrentDestinationHolder`, pass to `HedvigApp`. - ---- - -## Notes for the implementing engineer - -- **This repo is highly modular.** A change in `navigation-common` (Task 1) recompiles every dependent module. After the marker interfaces exist, each feature key change (Tasks 2–4) is local to that feature module. -- **Verification commands:** `./gradlew :app:testDebugUnitTest` compiles the `:app` module **and its entire dependency graph** (all the feature modules touched here) and runs `BackstackControllerTest`. That is the single best gate for almost every task. Run `./gradlew ktlintFormat` before each commit and `./gradlew ktlintCheck` to confirm. -- **Gradle exit-code trap (known issue):** do NOT pipe gradle through `tail`/`grep` to read results — a pipe returns the pipe's exit code, not gradle's, so a `BUILD FAILED` can look like success. Run the gradle command bare and read its own `BUILD SUCCESSFUL` / `BUILD FAILED` line and exit status. -- **ktlint flags unused imports.** Every task that removes the last use of an import MUST also remove that import, or `ktlintCheck` fails. Exact import removals are spelled out per task. -- Commit after each task. Stay on the current branch `eng/metro-nav3-pr2-nav2-to-nav3`. Do NOT push or open a PR unless explicitly asked. - ---- - -### Task 1: Add the three capability marker interfaces to `navigation-common` - -**Files:** -- Modify: `app/navigation/navigation-common/src/commonMain/kotlin/com/hedvig/android/navigation/common/HedvigNavKey.kt` - -- [ ] **Step 1: Add the marker interfaces** - -Replace the entire file contents with: - -```kotlin -package com.hedvig.android.navigation.common - -import androidx.navigation3.runtime.NavKey -import kotlin.reflect.KType - -interface HedvigNavKey : NavKey - -interface NavKeyTypeAware { - val typeList: List -} - -/** - * A destination on which the cross-sell bottom sheet is allowed to appear after a member finishes a - * flow (moving, edit co-insured, add/upgrade addon, change tier). Implemented by the screens a member - * lands on at the end of those flows. Replaces the old per-feature - * `xxxCrossSellBottomSheetPermittingDestinations` lists. - */ -interface CrossSellEligibleDestination - -/** - * A destination where an incoming chat push notification must be suppressed (the in-app screen shows - * the new message itself). Replaces `listOfDestinationsWhichShouldNotShowChatNotification`. - */ -interface SuppressesChatPushNotification - -/** - * A destination from which reaching the logged-out state is treated as a deliberate "log me out now" - * action, so the session is discarded rather than stashed for a same-member restore. Restoring the - * nav back to this screen after a fresh login would be wrong. Replaces the dead - * `destinationToExcludeFromSavingState`. - */ -interface DeliberateLogoutOrigin -``` - -- [ ] **Step 2: Verify it compiles** - -Run: `./gradlew :navigation-common:compileKotlinMetadata` -Expected: `BUILD SUCCESSFUL`. (If this task path is unavailable in the multiplatform setup, fall back to `./gradlew :app:compileDebugKotlin` which transitively compiles `navigation-common`.) - -- [ ] **Step 3: ktlint** - -Run: `./gradlew ktlintFormat && ./gradlew ktlintCheck` -Expected: `BUILD SUCCESSFUL`. - -- [ ] **Step 4: Commit** - -```bash -git add app/navigation/navigation-common/src/commonMain/kotlin/com/hedvig/android/navigation/common/HedvigNavKey.kt -git commit -m "$(cat <<'EOF' -feat(nav): add capability marker interfaces in navigation-common - -Adds CrossSellEligibleDestination, SuppressesChatPushNotification and -DeliberateLogoutOrigin markers to replace centralized destination -classification lists. -EOF -)" -``` - ---- - -### Task 2: Convert cross-sell eligibility to `CrossSellEligibleDestination` - -**Files:** -- Modify: `app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/navigation/HomeDestinations.kt` -- Modify: `app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/navigation/InsurancesNavigation.kt` -- Modify: `app/feature/feature-travel-certificate/src/main/kotlin/com/hedvig/android/feature/travelcertificate/navigation/TravelCertificateDestination.kt` -- Modify: `app/feature/feature-help-center/src/commonMain/kotlin/com/hedvig/android/feature/help/center/navigation/HelpCenterDestination.kt` -- Modify: `app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigAppState.kt` - -- [ ] **Step 1: Mark `HomeKey` and delete the home list** - -In `HomeDestinations.kt`, change the `HomeKey` declaration and delete the trailing val. Final file: - -```kotlin -package com.hedvig.android.feature.home.home.navigation - -import com.hedvig.android.navigation.common.CrossSellEligibleDestination -import com.hedvig.android.navigation.common.HedvigNavKey -import com.hedvig.android.navigation.common.NavKeyTypeAware -import com.hedvig.android.ui.emergency.FirstVetSection -import kotlin.reflect.KType -import kotlin.reflect.typeOf -import kotlinx.serialization.Serializable - -@Serializable -data object HomeKey : HedvigNavKey, CrossSellEligibleDestination - -@Serializable -internal data class FirstVetKey(val sections: List) : HedvigNavKey { - companion object : NavKeyTypeAware { - override val typeList: List = listOf(typeOf>()) - } -} -``` - -Note: the `KClass` import is removed (the deleted val was its only use). - -- [ ] **Step 2: Mark insurances keys and delete the insurances list** - -In `InsurancesNavigation.kt`, final file: - -```kotlin -package com.hedvig.android.feature.insurances.navigation - -import com.hedvig.android.navigation.common.CrossSellEligibleDestination -import com.hedvig.android.navigation.common.HedvigNavKey -import com.hedvig.android.navigation.core.DeepLinkAncestry -import com.hedvig.android.navigation.core.TopLevelGraph -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data object InsurancesKey : HedvigNavKey, CrossSellEligibleDestination - -@Serializable -internal data class InsuranceContractDetailKey( - /** Must match the name of the param inside [com.hedvig.android.navigation.core.HedvigDeepLinkContainer.contract] */ - @SerialName("contractId") - val contractId: String, -) : HedvigNavKey, DeepLinkAncestry, CrossSellEligibleDestination { - override val owningTab = TopLevelGraph.Insurances - override val syntheticParents = emptyList() -} - -@Serializable -internal data object TerminatedInsurancesKey : HedvigNavKey -``` - -Note: the `kotlin.reflect.KClass` import is removed. - -- [ ] **Step 3: Mark `TravelCertificateKey` and delete the travel-certificate list** - -In `TravelCertificateDestination.kt`: change line 12-13 and delete lines 48-50. Apply: - -```kotlin -@Serializable -data object TravelCertificateKey : HedvigNavKey, CrossSellEligibleDestination -``` - -Delete: - -```kotlin -val travelCertificateCrossSellBottomSheetPermittingDestinations: List> = listOf( - TravelCertificateKey::class, -) -``` - -Then remove the now-unused `import kotlin.reflect.KClass` and add `import com.hedvig.android.navigation.common.CrossSellEligibleDestination`. (`kotlin.reflect.KType` and `kotlin.reflect.typeOf` stay — still used by `TravelCertificateTravellersInputKey` / `ShowCertificateKey`.) - -- [ ] **Step 4: Mark help-center keys and delete the help-center list** - -In `HelpCenterDestination.kt`: add the marker to the three keys and delete the val. Apply these three edits: - -```kotlin -@Serializable -data object HelpCenterKey : HedvigNavKey, CrossSellEligibleDestination -``` - -```kotlin -@Serializable -internal data class HelpCenterTopicKey( - /** Must match the name of the param inside [com.hedvig.android.navigation.core.HedvigDeepLinkContainer] */ - @SerialName("id") - val topicId: String = "", -) : HedvigNavKey, CrossSellEligibleDestination -``` - -```kotlin -@Serializable -internal data class HelpCenterQuestionKey( - /** Must match the name of the param inside [com.hedvig.android.navigation.core.HedvigDeepLinkContainer] */ - @SerialName("id") - val questionId: String = "", -) : HedvigNavKey, CrossSellEligibleDestination -``` - -Delete: - -```kotlin -val helpCenterCrossSellBottomSheetPermittingDestinations: List> = listOf( - HelpCenterKey::class, - HelpCenterTopicKey::class, - HelpCenterQuestionKey::class, -) -``` - -Then remove the now-unused `import kotlin.reflect.KClass` and add `import com.hedvig.android.navigation.common.CrossSellEligibleDestination`. (`KType`/`typeOf` stay — used by `EmergencyKey`/`FirstVetKey`.) - -- [ ] **Step 5: Switch `HedvigAppState` cross-sell check to the marker** - -In `HedvigAppState.kt`: - -Remove these four imports (lines 20-23): - -```kotlin -import com.hedvig.android.feature.help.center.navigation.helpCenterCrossSellBottomSheetPermittingDestinations -import com.hedvig.android.feature.home.home.navigation.homeCrossSellBottomSheetPermittingDestinations -import com.hedvig.android.feature.insurances.navigation.insurancesCrossSellBottomSheetPermittingDestinations -import com.hedvig.android.feature.travelcertificate.navigation.travelCertificateCrossSellBottomSheetPermittingDestinations -``` - -Add this import (keep alphabetical ordering in the `com.hedvig.android.navigation.common` group): - -```kotlin -import com.hedvig.android.navigation.common.CrossSellEligibleDestination -``` - -Remove the now-unused `import kotlin.reflect.KClass` (line 32). - -Replace the `isInScreenEligibleForCrossSells` getter (lines 115-119): - -```kotlin - val isInScreenEligibleForCrossSells: Boolean - get() { - val destination = currentDestination ?: return false - return crossSellBottomSheetPermittingDestinations.any { it.isInstance(destination) } - } -``` - -with: - -```kotlin - val isInScreenEligibleForCrossSells: Boolean - get() = currentDestination is CrossSellEligibleDestination -``` - -Delete the entire private val + its KDoc at the bottom of the file (lines 183-197): - -```kotlin -/** - * Destinations that must show the cross-sell bottom sheet after finishing some flow - */ -private val crossSellBottomSheetPermittingDestinations: List> = buildList { - // Screens that a member will end up in after finishing any of the following flows - // 1. Moving flow - // 2. Edit co-insured - // 3. Add/Upgrade addon - // 4. Change tier - addAll(insurancesCrossSellBottomSheetPermittingDestinations) - addAll(helpCenterCrossSellBottomSheetPermittingDestinations) - addAll(travelCertificateCrossSellBottomSheetPermittingDestinations) - // One could finish those flows after a deep link, so the app's start destination must also be included - addAll(homeCrossSellBottomSheetPermittingDestinations) -} -``` - -Note: `HedvigNavKey` import stays — still used by the `currentDestination` getter (removed later in Task 7). - -- [ ] **Step 6: Build + test** - -Run: `./gradlew :app:testDebugUnitTest` -Expected: `BUILD SUCCESSFUL`, existing tests pass. - -- [ ] **Step 7: ktlint** - -Run: `./gradlew ktlintFormat && ./gradlew ktlintCheck` -Expected: `BUILD SUCCESSFUL`. - -- [ ] **Step 8: Commit** - -```bash -git add app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/navigation/HomeDestinations.kt app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/navigation/InsurancesNavigation.kt app/feature/feature-travel-certificate/src/main/kotlin/com/hedvig/android/feature/travelcertificate/navigation/TravelCertificateDestination.kt app/feature/feature-help-center/src/commonMain/kotlin/com/hedvig/android/feature/help/center/navigation/HelpCenterDestination.kt app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigAppState.kt -git commit -m "$(cat <<'EOF' -refactor(nav): mark cross-sell-eligible destinations via marker interface - -Replaces the four per-feature CrossSellBottomSheetPermittingDestinations -lists and HedvigAppState's aggregating buildList with a -CrossSellEligibleDestination marker implemented per key. -EOF -)" -``` - ---- - -### Task 3: Convert chat-notification suppression to `SuppressesChatPushNotification` - -**Files:** -- Modify: `app/feature/feature-chat/src/main/kotlin/com/hedvig/android/feature/chat/navigation/ChatDestination.kt` -- Modify: `app/feature/feature-claim-details/src/main/kotlin/com/hedvig/android/feature/claim/details/navigation/ClaimDetailDestinations.kt` -- Modify: `app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/navigation/HomeDestinations.kt` -- Modify: `app/app/src/main/kotlin/com/hedvig/android/app/notification/senders/ChatNotificationSender.kt` - -- [ ] **Step 1: Mark chat keys** - -In `ChatDestination.kt`, final file: - -```kotlin -package com.hedvig.android.feature.chat.navigation - -import com.hedvig.android.navigation.common.HedvigNavKey -import com.hedvig.android.navigation.common.SuppressesChatPushNotification -import kotlinx.serialization.Serializable - -@Serializable -data object InboxKey : HedvigNavKey, SuppressesChatPushNotification - -@Serializable -data class ChatKey( - val conversationId: String, -) : HedvigNavKey, SuppressesChatPushNotification -``` - -- [ ] **Step 2: Mark `ClaimDetailsKey`** - -In `ClaimDetailDestinations.kt`, add the import and the marker to `ClaimDetailsKey` only (NOT `AddFilesKey`): - -```kotlin -package com.hedvig.android.feature.claim.details.navigation - -import com.hedvig.android.navigation.common.HedvigNavKey -import com.hedvig.android.navigation.common.SuppressesChatPushNotification -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class ClaimDetailsKey( - /** - * The ID to the claim. Must match the name of the param inside in HedvigDeepLinkContainer - */ - @SerialName("claimId") - val claimId: String, -) : HedvigNavKey, SuppressesChatPushNotification - -@Serializable -internal data class AddFilesKey( - val targetUploadUrl: String, - val initialFilesUri: List, -) : HedvigNavKey -``` - -- [ ] **Step 3: Add the chat-suppression marker to `HomeKey`** - -In `HomeDestinations.kt`, `HomeKey` already implements `CrossSellEligibleDestination` from Task 2. Add the second marker and its import. Change: - -```kotlin -import com.hedvig.android.navigation.common.CrossSellEligibleDestination -import com.hedvig.android.navigation.common.HedvigNavKey -import com.hedvig.android.navigation.common.NavKeyTypeAware -``` - -to: - -```kotlin -import com.hedvig.android.navigation.common.CrossSellEligibleDestination -import com.hedvig.android.navigation.common.HedvigNavKey -import com.hedvig.android.navigation.common.NavKeyTypeAware -import com.hedvig.android.navigation.common.SuppressesChatPushNotification -``` - -and change: - -```kotlin -@Serializable -data object HomeKey : HedvigNavKey, CrossSellEligibleDestination -``` - -to: - -```kotlin -@Serializable -data object HomeKey : HedvigNavKey, CrossSellEligibleDestination, SuppressesChatPushNotification -``` - -- [ ] **Step 4: Switch `ChatNotificationSender` suppression check to the marker** - -In `ChatNotificationSender.kt`: - -Remove these four feature-key imports (lines 23-26): - -```kotlin -import com.hedvig.android.feature.chat.navigation.ChatKey -import com.hedvig.android.feature.chat.navigation.InboxKey -import com.hedvig.android.feature.claim.details.navigation.ClaimDetailsKey -import com.hedvig.android.feature.home.home.navigation.HomeKey -``` - -Add (in the `com.hedvig.android.navigation.common` import group): - -```kotlin -import com.hedvig.android.navigation.common.SuppressesChatPushNotification -``` - -Delete the `listOfDestinationsWhichShouldNotShowChatNotification` val (lines 50-55): - -```kotlin -private val listOfDestinationsWhichShouldNotShowChatNotification = setOf( - ChatKey::class, - InboxKey::class, - HomeKey::class, - ClaimDetailsKey::class, -) -``` - -Replace the suppression-check block inside `sendNotification` (lines 68-72): - -```kotlin - val currentDestination = CurrentDestinationInMemoryStorage.currentDestination - val currentlyOnDestinationWhichForbidsShowingChatNotification = - listOfDestinationsWhichShouldNotShowChatNotification.any { clazz -> - clazz.isInstance(currentDestination) - } -``` - -with: - -```kotlin - val currentDestination = CurrentDestinationInMemoryStorage.currentDestination - val currentlyOnDestinationWhichForbidsShowingChatNotification = - currentDestination is SuppressesChatPushNotification -``` - -(`CurrentDestinationInMemoryStorage` is still used here — it is replaced in Task 6. The `HedvigNavKey` import stays — still used by the `CurrentDestinationInMemoryStorage` object declaration.) - -- [ ] **Step 5: Build** - -Run: `./gradlew :app:compileDebugKotlin` -Expected: `BUILD SUCCESSFUL`. - -- [ ] **Step 6: ktlint** - -Run: `./gradlew ktlintFormat && ./gradlew ktlintCheck` -Expected: `BUILD SUCCESSFUL`. - -- [ ] **Step 7: Commit** - -```bash -git add app/feature/feature-chat/src/main/kotlin/com/hedvig/android/feature/chat/navigation/ChatDestination.kt app/feature/feature-claim-details/src/main/kotlin/com/hedvig/android/feature/claim/details/navigation/ClaimDetailDestinations.kt app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/navigation/HomeDestinations.kt app/app/src/main/kotlin/com/hedvig/android/app/notification/senders/ChatNotificationSender.kt -git commit -m "$(cat <<'EOF' -refactor(nav): suppress chat notifications via marker interface - -Replaces listOfDestinationsWhichShouldNotShowChatNotification with a -SuppressesChatPushNotification marker on ChatKey, InboxKey, HomeKey and -ClaimDetailsKey. -EOF -)" -``` - ---- - -### Task 4: Mark `ProfileKey` as `DeliberateLogoutOrigin` and delete dead code - -**Files:** -- Modify: `app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/navigation/ProfileDestinations.kt` - -- [ ] **Step 1: Add the marker and delete the dead val** - -Final file: - -```kotlin -package com.hedvig.android.feature.profile.navigation - -import com.hedvig.android.navigation.common.DeliberateLogoutOrigin -import com.hedvig.android.navigation.common.HedvigNavKey -import kotlinx.serialization.Serializable - -@Serializable -data object ProfileKey : HedvigNavKey, DeliberateLogoutOrigin - -@Serializable -data object ContactInfoKey : HedvigNavKey - -@Serializable -internal data object EurobonusKey : HedvigNavKey - -@Serializable -internal data object CertificatesKey : HedvigNavKey - -@Serializable -internal data object InformationKey : HedvigNavKey - -@Serializable -internal data object LicensesKey : HedvigNavKey - -@Serializable -internal data object SettingsKey : HedvigNavKey -``` - -This adds the `DeliberateLogoutOrigin` import + marker, and removes both the `import kotlin.reflect.KClass` and the dead `destinationToExcludeFromSavingState` val (verified to have zero references outside its own declaration). - -- [ ] **Step 2: Build** - -Run: `./gradlew :app:compileDebugKotlin` -Expected: `BUILD SUCCESSFUL`. - -- [ ] **Step 3: ktlint** - -Run: `./gradlew ktlintFormat && ./gradlew ktlintCheck` -Expected: `BUILD SUCCESSFUL`. - -- [ ] **Step 4: Commit** - -```bash -git add app/feature/feature-profile/src/main/kotlin/com/hedvig/android/feature/profile/navigation/ProfileDestinations.kt -git commit -m "$(cat <<'EOF' -refactor(nav): mark ProfileKey as DeliberateLogoutOrigin - -Adds the marker and removes the dead destinationToExcludeFromSavingState -val it replaces. -EOF -)" -``` - ---- - -### Task 5: Reinstate "deliberate logout from Profile discards the session" in `setLoggedOut` - -This is the lost Nav2 feature: logging out while on Profile is the normal "I deliberately log out now" action, so the session must NOT be stashed for a same-member restore (restoring the nav back to Profile after a fresh login is wrong). - -**Files:** -- Modify: `app/app/src/test/kotlin/com/hedvig/android/app/navigation/BackstackControllerTest.kt` -- Modify: `app/app/src/main/kotlin/com/hedvig/android/app/navigation/BackstackController.kt` - -- [ ] **Step 1: Adjust the existing stash test so it no longer logs out from a deliberate-logout origin** - -The existing test `setLoggedOut stashes the live session tagged with the member id` (lines 157-169) currently navigates to Profile and expects a stash — that contradicts the new behavior. Change it to land on a non-deliberate-origin destination (Payments) while still exercising parked-run capture. Replace the test body: - -```kotlin - @Test - fun `setLoggedOut stashes the live session tagged with the member id`() { - val controller = controllerWith(HomeKey, InsurancesKey, HelpCenterKey) - controller.selectTopLevel(TopLevelGraph.Profile) // park Insurances run, render Profile root - controller.setLoggedOut("mem-1") - assertThat(controller.entries.toList()).containsExactly(LoginKey) - assertThat(controller.parkedRuns).isEmpty() - val stash = controller.stashedSession!! - assertThat(stash.memberId).isEqualTo("mem-1") - assertThat(stash.entries).containsExactly(HomeKey, ProfileKey) - assertThat(stash.parkedRuns[TopLevelGraph.Insurances]) - .isEqualTo(listOf(InsurancesKey, HelpCenterKey)) - } -``` - -with: - -```kotlin - @Test - fun `setLoggedOut stashes the live session tagged with the member id`() { - val controller = controllerWith(HomeKey, InsurancesKey, HelpCenterKey) - controller.selectTopLevel(TopLevelGraph.Payments) // park Insurances run, render Payments root - controller.setLoggedOut("mem-1") - assertThat(controller.entries.toList()).containsExactly(LoginKey) - assertThat(controller.parkedRuns).isEmpty() - val stash = controller.stashedSession!! - assertThat(stash.memberId).isEqualTo("mem-1") - assertThat(stash.entries).containsExactly(HomeKey, PaymentsKey) - assertThat(stash.parkedRuns[TopLevelGraph.Insurances]) - .isEqualTo(listOf(InsurancesKey, HelpCenterKey)) - } -``` - -(`PaymentsKey` is already imported at line 17.) - -- [ ] **Step 2: Add the new failing test** - -Add this test immediately after the test edited in Step 1: - -```kotlin - @Test - fun `setLoggedOut from a deliberate-logout origin stashes nothing even with a member id`() { - val controller = controllerWith(HomeKey, ProfileKey) - controller.setLoggedOut("mem-1") - assertThat(controller.entries.toList()).containsExactly(LoginKey) - assertThat(controller.parkedRuns).isEmpty() - assertThat(controller.stashedSession).isEqualTo(null) - } -``` - -- [ ] **Step 3: Run tests to verify the new test fails** - -Run: `./gradlew :app:testDebugUnitTest --tests "com.hedvig.android.app.navigation.BackstackControllerTest"` -Expected: the Step-1 test PASSES (lands on Payments, still stashes); the Step-2 test FAILS — `stashedSession` is non-null because `setLoggedOut` currently always stashes when `memberId != null`. - -- [ ] **Step 4: Implement the behavior in `setLoggedOut`** - -In `BackstackController.kt`, add the import (in the `com.hedvig.android.navigation.common` group, after the `HedvigNavKey` import on line 23): - -```kotlin -import com.hedvig.android.navigation.common.DeliberateLogoutOrigin -``` - -Replace `setLoggedOut` (lines 260-270): - -```kotlin - fun setLoggedOut(memberId: String?) { - Snapshot.withMutableSnapshot { - stashedSession = if (memberId != null) { - StashedSession(memberId, entries.toList(), parkedRuns.toMap()) - } else { - null - } - parkedRuns.clear() - entries.replaceWith(listOf(LoginKey)) - } - } -``` - -with: - -```kotlin - fun setLoggedOut(memberId: String?) { - Snapshot.withMutableSnapshot { - val isDeliberateLogout = entries.lastOrNull() is DeliberateLogoutOrigin - stashedSession = if (memberId != null && !isDeliberateLogout) { - StashedSession(memberId, entries.toList(), parkedRuns.toMap()) - } else { - null - } - parkedRuns.clear() - entries.replaceWith(listOf(LoginKey)) - } - } -``` - -Also update the KDoc above `setLoggedOut` (lines 254-259) to document the new case. Replace: - -```kotlin - /** - * Drop to the login root. Stashes the live session (tagged with [memberId]) so a same-member - * re-login can restore the history; the stash is excluded from [allLiveContentKeys], so the - * decorators dispose every key's per-entry state while it waits. A null [memberId] (demo mode / - * unknown identity) stashes nothing — that session can never be safely restored. - */ -``` - -with: - -```kotlin - /** - * Drop to the login root. Stashes the live session (tagged with [memberId]) so a same-member - * re-login can restore the history; the stash is excluded from [allLiveContentKeys], so the - * decorators dispose every key's per-entry state while it waits. A null [memberId] (demo mode / - * unknown identity) stashes nothing — that session can never be safely restored. Logging out while - * the top destination is a [DeliberateLogoutOrigin] (Profile) is treated as an intentional sign-out, - * so nothing is stashed even with a known [memberId] — restoring the member onto that screen would - * be wrong. - */ -``` - -- [ ] **Step 5: Run tests to verify they pass** - -Run: `./gradlew :app:testDebugUnitTest --tests "com.hedvig.android.app.navigation.BackstackControllerTest"` -Expected: PASS (both the edited stash test and the new deliberate-logout test). - -- [ ] **Step 6: ktlint** - -Run: `./gradlew ktlintFormat && ./gradlew ktlintCheck` -Expected: `BUILD SUCCESSFUL`. - -- [ ] **Step 7: Commit** - -```bash -git add app/app/src/main/kotlin/com/hedvig/android/app/navigation/BackstackController.kt app/app/src/test/kotlin/com/hedvig/android/app/navigation/BackstackControllerTest.kt -git commit -m "$(cat <<'EOF' -feat(nav): discard session on deliberate logout from Profile - -setLoggedOut no longer stashes the session for a same-member restore when -the top destination is a DeliberateLogoutOrigin, reinstating the Nav2 -behavior lost in the Nav3 migration. -EOF -)" -``` - ---- - -### Task 6: Replace `CurrentDestinationInMemoryStorage` with an injected `CurrentDestinationHolder` - -Goal: kill the global mutable `object` (written from a Composable effect, read off the FCM/binder thread) and replace it with a `@SingleIn(AppScope::class)` holder exposing a thread-safe `StateFlow`, written by a dedicated effect and read via constructor injection. - -**Files:** -- Create: `app/app/src/main/kotlin/com/hedvig/android/app/navigation/CurrentDestinationHolder.kt` -- Modify: `app/app/src/main/kotlin/com/hedvig/android/app/notification/senders/ChatNotificationSender.kt` -- Modify: `app/app/src/main/kotlin/com/hedvig/android/app/di/ApplicationMetroProviders.kt` -- Modify: `app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigAppState.kt` -- Modify: `app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigApp.kt` -- Modify: `app/app/src/main/kotlin/com/hedvig/android/app/MainActivity.kt` - -- [ ] **Step 1: Create the holder** - -Create `app/app/src/main/kotlin/com/hedvig/android/app/navigation/CurrentDestinationHolder.kt`: - -```kotlin -package com.hedvig.android.app.navigation - -import com.hedvig.android.core.common.di.AppScope -import com.hedvig.android.navigation.common.HedvigNavKey -import dev.zacsweers.metro.Inject -import dev.zacsweers.metro.SingleIn -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow - -/** - * App-scoped source of truth for the destination currently on top of the rendered stack, published - * as a [StateFlow] so non-Composable consumers (e.g. [com.hedvig.android.app.notification.senders.ChatNotificationSender], - * which runs on the FCM/binder thread) can read it safely. Written by `ReportCurrentDestinationEffect`. - * - * This is intentionally non-persistent: a process death wipes it, which is the desired behavior — - * the suppression it powers only matters while the app is resumed, and over-showing a notification is - * preferable to wrongly hiding one. - */ -@SingleIn(AppScope::class) -@Inject -class CurrentDestinationHolder { - private val currentDestinationState = MutableStateFlow(null) - val currentDestination: StateFlow = currentDestinationState.asStateFlow() - - fun update(destination: HedvigNavKey?) { - currentDestinationState.value = destination - } -} -``` - -- [ ] **Step 2: Make `ChatNotificationSender` read the injected holder; remove the global object** - -In `ChatNotificationSender.kt`: - -Delete the `CurrentDestinationInMemoryStorage` object and its KDoc (lines 37-48): - -```kotlin -/** - * An in-memory storage of the current route, used to *not* show the chat notification if we are in a select list of - * screens where we do not want to show the system notification, but we want to let the in-app screen indicate that - * there is a new message. - * This is not persistent storage, and will just be wiped in scenarios like the process being killed, but this is part - * of what we want, since we only care to do this if the app is resumed anyway. On top of this, we'd rather experience - * cases where we show the notification when we shouldn't rather than cases where we do not show the notification even - * thought we should. - */ -object CurrentDestinationInMemoryStorage { - var currentDestination: HedvigNavKey? = null -} -``` - -Add the holder as a constructor parameter. Change the class header: - -```kotlin -class ChatNotificationSender( - private val context: Context, - private val permissionManager: PermissionManager, - private val buildConstants: HedvigBuildConstants, - private val hedvigDeepLinkContainer: HedvigDeepLinkContainer, - private val notificationChannel: HedvigNotificationChannel, -) : NotificationSender { -``` - -to: - -```kotlin -class ChatNotificationSender( - private val context: Context, - private val permissionManager: PermissionManager, - private val buildConstants: HedvigBuildConstants, - private val hedvigDeepLinkContainer: HedvigDeepLinkContainer, - private val notificationChannel: HedvigNotificationChannel, - private val currentDestinationHolder: CurrentDestinationHolder, -) : NotificationSender { -``` - -Change the read inside `sendNotification`: - -```kotlin - val currentDestination = CurrentDestinationInMemoryStorage.currentDestination -``` - -to: - -```kotlin - val currentDestination = currentDestinationHolder.currentDestination.value -``` - -Add the import: - -```kotlin -import com.hedvig.android.app.navigation.CurrentDestinationHolder -``` - -The `HedvigNavKey` import is now unused here (the object that referenced it is gone, and `is SuppressesChatPushNotification` does not name it) — remove `import com.hedvig.android.navigation.common.HedvigNavKey`. - -- [ ] **Step 3: Inject the holder into the Metro provider** - -In `ApplicationMetroProviders.kt`, replace `provideChatNotificationSender` (lines 210-224): - -```kotlin - @Provides - @SingleIn(AppScope::class) - @IntoSet - fun provideChatNotificationSender( - applicationContext: Context, - permissionManager: PermissionManager, - buildConstants: HedvigBuildConstants, - deepLinkContainer: HedvigDeepLinkContainer, - ): NotificationSender = ChatNotificationSender( - applicationContext, - permissionManager, - buildConstants, - deepLinkContainer, - HedvigNotificationChannel.Chat, - ) -``` - -with: - -```kotlin - @Provides - @SingleIn(AppScope::class) - @IntoSet - fun provideChatNotificationSender( - applicationContext: Context, - permissionManager: PermissionManager, - buildConstants: HedvigBuildConstants, - deepLinkContainer: HedvigDeepLinkContainer, - currentDestinationHolder: CurrentDestinationHolder, - ): NotificationSender = ChatNotificationSender( - applicationContext, - permissionManager, - buildConstants, - deepLinkContainer, - HedvigNotificationChannel.Chat, - currentDestinationHolder, - ) -``` - -Add the import (alphabetical, in the `com.hedvig.android.app.navigation` group): - -```kotlin -import com.hedvig.android.app.navigation.CurrentDestinationHolder -``` - -- [ ] **Step 4: Remove the destination-mirroring effect from `rememberHedvigAppState`** - -In `HedvigAppState.kt`, delete the `LaunchedEffect` block (lines 69-74): - -```kotlin - LaunchedEffect(appState) { - snapshotFlow { appState.currentDestination }.collect { destination -> - logcat { "Navigated to destination:$destination" } - CurrentDestinationInMemoryStorage.currentDestination = destination - } - } -``` - -so `rememberHedvigAppState` ends with `return appState` directly after the `remember { … }` block. - -Remove the now-unused imports: - -```kotlin -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.snapshotFlow -import com.hedvig.android.app.notification.senders.CurrentDestinationInMemoryStorage -import com.hedvig.android.logger.logcat -``` - -(`collectAsState` and `getValue` stay — used by `darkTheme`.) - -- [ ] **Step 5: Add `ReportCurrentDestinationEffect` and thread the holder through `HedvigApp`** - -In `HedvigApp.kt`: - -Add the holder parameter to the `HedvigApp` signature, after `missedPaymentNotificationServiceProvider` (line 93): - -```kotlin - missedPaymentNotificationServiceProvider: Provider, - currentDestinationHolder: CurrentDestinationHolder, - dismissSplashScreen: () -> Unit, -``` - -Add a call to the new effect right after `rememberHedvigAppState(...)` returns (immediately after line 103's closing `)` of the `rememberHedvigAppState` call, before `val lastKnownMemberId`): - -```kotlin - ReportCurrentDestinationEffect(backstackController, currentDestinationHolder) -``` - -Add the effect as a private composable (place it next to the other private effects, e.g. after `DetermineStartDestinationEffect`): - -```kotlin -/** - * Mirrors the current top destination into the app-scoped [CurrentDestinationHolder] so non-Composable - * consumers (chat-notification suppression) can read it. Replaces the old LaunchedEffect that wrote the - * global CurrentDestinationInMemoryStorage object. - */ -@Composable -private fun ReportCurrentDestinationEffect( - backstackController: BackstackController, - currentDestinationHolder: CurrentDestinationHolder, -) { - LaunchedEffect(backstackController, currentDestinationHolder) { - snapshotFlow { backstackController.currentDestination }.collect { destination -> - logcat { "Navigated to destination:$destination" } - currentDestinationHolder.update(destination) - } - } -} -``` - -Add the import: - -```kotlin -import com.hedvig.android.app.navigation.CurrentDestinationHolder -``` - -(`LaunchedEffect`, `snapshotFlow`, `logcat`, `Composable` are all already imported in `HedvigApp.kt`.) - -- [ ] **Step 6: Inject the holder in `MainActivity` and pass it to `HedvigApp`** - -In `MainActivity.kt`: - -Add the injected field next to the other `@Inject` fields (e.g. after `missedPaymentNotificationServiceProvider`, line 96): - -```kotlin - @Inject private lateinit var currentDestinationHolder: CurrentDestinationHolder -``` - -Pass it to `HedvigApp` (after `missedPaymentNotificationServiceProvider = …`, line 204): - -```kotlin - missedPaymentNotificationServiceProvider = missedPaymentNotificationServiceProvider, - currentDestinationHolder = currentDestinationHolder, - dismissSplashScreen = { showSplash.update { false } }, -``` - -Add the import: - -```kotlin -import com.hedvig.android.app.navigation.CurrentDestinationHolder -``` - -- [ ] **Step 7: Build + test** - -Run: `./gradlew :app:testDebugUnitTest` -Expected: `BUILD SUCCESSFUL`, all tests pass. - -- [ ] **Step 8: ktlint** - -Run: `./gradlew ktlintFormat && ./gradlew ktlintCheck` -Expected: `BUILD SUCCESSFUL`. - -- [ ] **Step 9: Commit** - -```bash -git add app/app/src/main/kotlin/com/hedvig/android/app/navigation/CurrentDestinationHolder.kt app/app/src/main/kotlin/com/hedvig/android/app/notification/senders/ChatNotificationSender.kt app/app/src/main/kotlin/com/hedvig/android/app/di/ApplicationMetroProviders.kt app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigAppState.kt app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigApp.kt app/app/src/main/kotlin/com/hedvig/android/app/MainActivity.kt -git commit -m "$(cat <<'EOF' -refactor(nav): replace global current-destination storage with injected holder - -Introduces a @SingleIn(AppScope) CurrentDestinationHolder exposing a -StateFlow, written by ReportCurrentDestinationEffect and injected into -ChatNotificationSender, removing the global-mutable -CurrentDestinationInMemoryStorage object. -EOF -)" -``` - ---- - -### Task 7: Split the seam — remove `HedvigAppState` forwarders, thread the controller into `HedvigNavHost` - -After this task, `HedvigAppState` exposes only feature-knowledge state plus the `backstackController` reference (the bridge). All pure-navigation calls go straight to the controller. - -**Files:** -- Modify: `app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigAppState.kt` -- Modify: `app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigNavHost.kt` -- Modify: `app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigAppUi.kt` -- Modify: `app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigApp.kt` - -- [ ] **Step 1: Change `HedvigNavHost` to take the controller + windowSizeClass directly** - -In `HedvigNavHost.kt`: - -Change the signature (lines 106-122). Replace: - -```kotlin -internal fun HedvigNavHost( - hedvigAppState: HedvigAppState, - memberIdService: MemberIdService, -``` - -with: - -```kotlin -internal fun HedvigNavHost( - backstackController: BackstackController, - windowSizeClass: WindowSizeClass, - memberIdService: MemberIdService, -``` - -Replace the alias line (line 123): - -```kotlin - val backstack = hedvigAppState.backstackController -``` - -with: - -```kotlin - val backstack = backstackController -``` - -Now replace every remaining `hedvigAppState.backstackController` with `backstackController`, every `hedvigAppState.windowSizeClass` with `windowSizeClass`, every `hedvigAppState.navigateToTopLevelGraph(...)` with `backstackController.selectTopLevel(...)`, and the `hedvigAppState.navigateToLoggedIn(...)` call with `backstackController.setLoggedIn(...)`. Concretely: - -- Line 143: `val retainedContentKeys = { hedvigAppState.backstackController.allLiveContentKeys }` → `val retainedContentKeys = { backstackController.allLiveContentKeys }` -- Line 154: `backStack = hedvigAppState.backstackController.entries,` → `backStack = backstackController.entries,` -- Lines 156-159 (`onBack`): `if (!hedvigAppState.backstackController.handleBack()) {` → `if (!backstackController.handleBack()) {` -- Line 177: `backstack = hedvigAppState.backstackController,` (loginGraph) → `backstack = backstackController,` -- Lines 182-186 (`onNavigateToLoggedIn`): - - ```kotlin - onNavigateToLoggedIn = { - scope.launch { - hedvigAppState.navigateToLoggedIn(memberIdService.getMemberId().first()) - } - }, - ``` - - → - - ```kotlin - onNavigateToLoggedIn = { - scope.launch { - backstackController.setLoggedIn(memberIdService.getMemberId().first()) - } - }, - ``` -- Line 191: `backstack = hedvigAppState.backstackController,` (nestedHomeGraphs) → `backstack = backstackController,` -- Line 202: `backstack = hedvigAppState.backstackController,` (homeGraph) → `backstack = backstackController,` -- Line 228: `windowSizeClass = hedvigAppState.windowSizeClass,` → `windowSizeClass = windowSizeClass,` -- Line 229: `backstack = hedvigAppState.backstackController,` (terminateInsuranceGraph) → `backstack = backstackController,` -- Lines 233-236 (`navigateToInsurances`): - - ```kotlin - navigateToInsurances = { - backstack.popUpTo(inclusive = true) - hedvigAppState.navigateToTopLevelGraph(TopLevelGraph.Insurances) - }, - ``` - - → - - ```kotlin - navigateToInsurances = { - backstack.popUpTo(inclusive = true) - backstackController.selectTopLevel(TopLevelGraph.Insurances) - }, - ``` -- Line 265: `backstack = hedvigAppState.backstackController,` (insuranceGraph) → `backstack = backstackController,` -- Line 317: `backstack = hedvigAppState.backstackController,` (paymentsGraph) → `backstack = backstackController,` -- Line 325: `backstack = hedvigAppState.backstackController,` (payoutAccountGraph) → `backstack = backstackController,` -- Line 332: `deleteAccountGraph(hedvigAppState.backstackController)` → `deleteAccountGraph(backstackController)` -- Line 341: `backstack = hedvigAppState.backstackController,` (profileGraph) → `backstack = backstackController,` -- Line 369: `backstack = hedvigAppState.backstackController,` (cbmChatGraph) → `backstack = backstackController,` -- Line 372: `backstack = hedvigAppState.backstackController,` (addonPurchaseNavGraph) → `backstack = backstackController,` -- Line 381: `backstack = hedvigAppState.backstackController,` (changeTierGraph) → `backstack = backstackController,` -- Line 385: `backstack = hedvigAppState.backstackController,` (chipIdGraph) → `backstack = backstackController,` -- Lines 389-392 (`goHome`): - - ```kotlin - goHome = { - backstack.popUpTo(inclusive = true) - hedvigAppState.navigateToTopLevelGraph(TopLevelGraph.Home) - }, - ``` - - → - - ```kotlin - goHome = { - backstack.popUpTo(inclusive = true) - backstackController.selectTopLevel(TopLevelGraph.Home) - }, - ``` -- Line 395: `backstack = hedvigAppState.backstackController,` (movingFlowGraph) → `backstack = backstackController,` -- Line 398: `connectPaymentGraph(backstack = hedvigAppState.backstackController)` → `connectPaymentGraph(backstack = backstackController)` -- Line 399: `editCoInsuredGraph(hedvigAppState.backstackController)` → `editCoInsuredGraph(backstackController)` -- Line 401: `backstack = hedvigAppState.backstackController,` (helpCenterGraph) → `backstack = backstackController,` -- Line 458: `imageViewerGraph(hedvigAppState.backstackController, imageLoader)` → `imageViewerGraph(backstackController, imageLoader)` -- Line 459: `removeAddonsNavGraph(backstack = hedvigAppState.backstackController)` → `removeAddonsNavGraph(backstack = backstackController)` - -After these replacements there must be zero remaining `hedvigAppState` references in the file. Verify with: `grep -n "hedvigAppState" app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigNavHost.kt` → no matches. - -Fix imports: remove `import com.hedvig.android.app.ui.HedvigAppState` (line 20). Add `import androidx.compose.material3.windowsizeclass.WindowSizeClass` (alphabetical within the `androidx.compose` group). `BackstackController` is in the same package — no import needed. - -- [ ] **Step 2: Update `HedvigAppUi` to read top-level via the controller and pass new args to `HedvigNavHost`** - -In `HedvigAppUi.kt`: - -Replace line 70: - -```kotlin - currentTopLevelGraph = hedvigAppState.currentTopLevelGraph, -``` - -with: - -```kotlin - currentTopLevelGraph = hedvigAppState.backstackController.currentTopLevel, -``` - -Replace line 71: - -```kotlin - onNavigateToTopLevelGraph = hedvigAppState::navigateToTopLevelGraph, -``` - -with: - -```kotlin - onNavigateToTopLevelGraph = hedvigAppState.backstackController::selectTopLevel, -``` - -Replace the `HedvigNavHost(...)` call (lines 95-110). Replace: - -```kotlin - HedvigNavHost( - hedvigAppState = hedvigAppState, - memberIdService = memberIdService, -``` - -with: - -```kotlin - HedvigNavHost( - backstackController = hedvigAppState.backstackController, - windowSizeClass = hedvigAppState.windowSizeClass, - memberIdService = memberIdService, -``` - -(`hedvigAppState.backstackController.navigateUp()` on line 82 and `hedvigAppState.backstackController.loneDeepLinkChrome` on line 85 stay as-is — they already go through the controller.) - -- [ ] **Step 3: Update the `HedvigApp` effects to call the controller directly** - -In `HedvigApp.kt`: - -`DetermineStartDestinationEffect` already receives `backstackController`. Replace its callback args (lines 115-116): - -```kotlin - onLoggedIn = { memberId -> hedvigAppState.navigateToLoggedIn(memberId) }, - onLoggedOut = { hedvigAppState.navigateToLoggedOut(lastKnownMemberId.value) }, -``` - -with: - -```kotlin - onLoggedIn = { memberId -> backstackController.setLoggedIn(memberId) }, - onLoggedOut = { backstackController.setLoggedOut(lastKnownMemberId.value) }, -``` - -Change the `LogoutOnInvalidCredentialsEffect` call site (lines 133-138): - -```kotlin - LogoutOnInvalidCredentialsEffect( - hedvigAppState, - authTokenService, - demoManager, - lastKnownMemberId = { lastKnownMemberId.value }, - ) -``` - -with: - -```kotlin - LogoutOnInvalidCredentialsEffect( - backstackController, - authTokenService, - demoManager, - lastKnownMemberId = { lastKnownMemberId.value }, - ) -``` - -Change the `LogoutOnInvalidCredentialsEffect` definition (lines 303-347). Replace its signature parameter and the two body references. Replace: - -```kotlin -private fun LogoutOnInvalidCredentialsEffect( - hedvigAppState: HedvigAppState, - authTokenService: AuthTokenService, - demoManager: DemoManager, - lastKnownMemberId: () -> String?, -) { -``` - -with: - -```kotlin -private fun LogoutOnInvalidCredentialsEffect( - backstackController: BackstackController, - authTokenService: AuthTokenService, - demoManager: DemoManager, - lastKnownMemberId: () -> String?, -) { -``` - -Replace (inside that effect) line 325: - -```kotlin - LaunchedEffect(lifecycle, hedvigAppState, authTokenService, demoManager) { -``` - -with: - -```kotlin - LaunchedEffect(lifecycle, backstackController, authTokenService, demoManager) { -``` - -Replace line 330: - -```kotlin - snapshotFlow { hedvigAppState.backstackController.isLoggedIn }, -``` - -with: - -```kotlin - snapshotFlow { backstackController.isLoggedIn }, -``` - -Replace line 342: - -```kotlin - hedvigAppState.navigateToLoggedOut(lastKnownMemberId()) -``` - -with: - -```kotlin - backstackController.setLoggedOut(lastKnownMemberId()) -``` - -- [ ] **Step 4: Remove the forwarders from `HedvigAppState`** - -In `HedvigAppState.kt`: - -Delete the `currentDestination` getter (lines 88-89): - -```kotlin - val currentDestination: HedvigNavKey? - get() = backstackController.currentDestination -``` - -Delete the `currentTopLevelGraph` getter (lines 91-92): - -```kotlin - val currentTopLevelGraph: TopLevelGraph - get() = backstackController.currentTopLevel -``` - -Delete the three navigation methods (lines 155-169): - -```kotlin - /** - * Navigate to a top level destination. Selecting the current tab again pops it back to its start; - * selecting another tab brings its run forward (or returns to Home), keeping Home pinned at the base. - */ - fun navigateToTopLevelGraph(topLevelGraph: TopLevelGraph) { - backstackController.selectTopLevel(topLevelGraph) - } - - fun navigateToLoggedIn(memberId: String?) { - backstackController.setLoggedIn(memberId) - } - - fun navigateToLoggedOut(memberId: String?) { - backstackController.setLoggedOut(memberId) - } -``` - -Update `isInScreenEligibleForCrossSells` to read the controller directly (it can no longer use the removed `currentDestination` forwarder). Replace: - -```kotlin - val isInScreenEligibleForCrossSells: Boolean - get() = currentDestination is CrossSellEligibleDestination -``` - -with: - -```kotlin - val isInScreenEligibleForCrossSells: Boolean - get() = backstackController.currentDestination is CrossSellEligibleDestination -``` - -Now `HedvigNavKey` is no longer referenced in this file — remove `import com.hedvig.android.navigation.common.HedvigNavKey`. `TopLevelGraph` is still referenced by `topLevelGraphs` — keep its import. - -- [ ] **Step 5: Build + test** - -Run: `./gradlew :app:testDebugUnitTest` -Expected: `BUILD SUCCESSFUL`, all tests pass. - -- [ ] **Step 6: Verify no leftover forwarders / stale references** - -Run: -```bash -grep -rn "navigateToLoggedIn\|navigateToLoggedOut\|navigateToTopLevelGraph\|\.currentTopLevelGraph\b" app/app/src/main/kotlin/com/hedvig/android/app -grep -n "hedvigAppState" app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigNavHost.kt -``` -Expected: no matches for the removed forwarder names; no `hedvigAppState` in `HedvigNavHost.kt`. - -- [ ] **Step 7: ktlint** - -Run: `./gradlew ktlintFormat && ./gradlew ktlintCheck` -Expected: `BUILD SUCCESSFUL`. - -- [ ] **Step 8: Full app assemble (final integration gate)** - -Run: `./gradlew :app:assembleDebug` -Expected: `BUILD SUCCESSFUL`. - -- [ ] **Step 9: Commit** - -```bash -git add app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigAppState.kt app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigNavHost.kt app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigAppUi.kt app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigApp.kt -git commit -m "$(cat <<'EOF' -refactor(nav): split HedvigAppState/BackstackController responsibilities - -Removes HedvigAppState's pure-navigation forwarders (currentDestination, -currentTopLevelGraph, navigateToTopLevelGraph/LoggedIn/LoggedOut) and -threads BackstackController + windowSizeClass directly into HedvigNavHost, -leaving HedvigAppState with only feature-knowledge state plus the -controller bridge. -EOF -)" -``` - ---- - -## Self-Review - -**Spec coverage:** -- (a) HedvigAppState/BackstackController split → Task 7. ✓ -- (b) three marker conversions in navigation-common → Task 1 (declare) + Task 2 (cross-sell) + Task 3 (chat) + Task 4 (logout). ✓ -- (c) DeliberateLogoutOrigin logout reinstatement in `setLoggedOut` → Task 5 (TDD). ✓ -- (d) CurrentDestinationHolder replacement (option A: generic holder, marker check at consumer) → Task 6. ✓ - -**Type consistency:** marker names (`CrossSellEligibleDestination`, `SuppressesChatPushNotification`, `DeliberateLogoutOrigin`) are used identically across Tasks 1–7. Holder API: `CurrentDestinationHolder.currentDestination: StateFlow` + `update(HedvigNavKey?)` — read as `.currentDestination.value` (Task 2/6) and written via `.update(...)` (Task 6). Controller methods used by callers: `selectTopLevel`, `setLoggedIn`, `setLoggedOut`, `currentTopLevel`, `currentDestination`, `isLoggedIn`, `navigateUp`, `loneDeepLinkChrome`, `allLiveContentKeys`, `handleBack`, `entries` — all exist on `BackstackController`. ✓ - -**Placeholder scan:** every code step contains complete code; every command has an expected result. ✓ - -**Ordering safety:** Task 1 only adds interfaces (safe). Tasks 2–4 each add markers + delete the corresponding registry atomically (build green after each). Task 5 is TDD and self-contained. Task 6 swaps storage without touching the split. Task 7 removes forwarders last, after every other consumer is settled. Each task ends with a green build + ktlint + commit. From 425396c63226655ab9e4527260f7f640110b1365 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Tue, 9 Jun 2026 11:09:29 +0300 Subject: [PATCH 12/12] WIP --- docs/architecture/navigation-and-di.md | 818 +++++++++++++++++++++++++ 1 file changed, 818 insertions(+) create mode 100644 docs/architecture/navigation-and-di.md diff --git a/docs/architecture/navigation-and-di.md b/docs/architecture/navigation-and-di.md new file mode 100644 index 0000000000..683a18203c --- /dev/null +++ b/docs/architecture/navigation-and-di.md @@ -0,0 +1,818 @@ +# Navigation & Dependency Injection — Engineering Design Spec + +> **Status:** current as of the Metro + Navigation 3 migration (PR #2976 and the follow-up work that landed on `develop`). +> +> **Audience:** every engineer who will touch this codebase. This document explains not just *what* the architecture is, but *why* each decision was made, what was rejected, and which invariants you must not break. The day-to-day quick reference is `CLAUDE.md`; this is the deep dive. + +--- + +## Table of contents + +1. [Why this migration happened](#1-why-this-migration-happened) +2. [The 30-second mental model](#2-the-30-second-mental-model) +3. [Part I — Dependency Injection with Metro](#part-i--dependency-injection-with-metro) +4. [Part II — Navigation 3 and the single back stack](#part-ii--navigation-3-and-the-single-back-stack) +5. [Part III — State persistence and the Activity seam](#part-iii--state-persistence-and-the-activity-seam) +6. [Part IV — Sessions, auth, and the splash gate](#part-iv--sessions-auth-and-the-splash-gate) +7. [Part V — Deep links and task management](#part-v--deep-links-and-task-management) +8. [Part VI — Invariants, decisions, and tests](#part-vi--invariants-decisions-and-tests) +9. [Part VII — How to do common things](#part-vii--how-to-do-common-things) +10. [Glossary](#glossary) + +--- + +## 1. Why this migration happened + +Two long-standing pain points drove this work. + +**DI: Koin was runtime-resolved.** With ~80 modules, a missing or mis-scoped binding surfaced as a crash at first use, not at build time. Koin also has no real story for Kotlin Multiplatform DI graphs, and we are pushing more and more shared code onto iOS via the umbrella framework. We wanted *compile-time* verification of the object graph and a contribution model that scales across modules without a central "god module" listing every binding. + +**Navigation: Nav2 fought us.** Jetpack Navigation 2 is built around a `NavController` that owns an opaque back stack of route strings. We repeatedly needed to do things the framework resisted: + +- Drive navigation from long-lived **Presenters** (Molecule), not just from composables holding a `NavController`. +- Implement **multiple back stacks** (per bottom-nav tab) with full state retention, including across process death. +- Build **synthetic back stacks** for deep links with correct Up behaviour, including the "we were launched into a foreign app's task" case. +- Keep navigation **type-safe** and KMP-friendly without route-string parsing. + +Navigation 3 (`androidx.navigation3`) inverts the ownership: *you* own the back stack as a plain observable list, and `NavDisplay` simply renders it. That inversion is exactly what let us move back-stack ownership into an app-scoped singleton and drive it from Presenters. Metro and Nav3 were adopted together because they reinforce each other — the app-scoped back stack controller is a Metro singleton, and feature serializer registrations are Metro multibindings generated by a KSP processor. + +The net effect: **the framework no longer owns navigation state — we do.** Everything in Part II follows from that single inversion. + +--- + +## 2. The 30-second mental model + +``` + ┌───────────────────────────────────────────────┐ + │ AppGraph │ + │ (single Metro @DependencyGraph, AppScope) │ + │ │ + feature modules ──────▶ @ContributesBinding / @Provides / @IntoSet/Map │ + contribute bindings │ │ + │ ┌──────────────────────┐ ┌───────────────┐ │ + │ │ BackstackController │ │ AppViewModel │ │ + │ │ @SingleIn(AppScope) │ │ Factory │ │ + │ │ owns ALL nav state │ └───────────────┘ │ + │ └───────────┬──────────┘ │ + └───────────────┼─────────────────────────────────┘ + │ entries: SnapshotStateList + ▼ + MainActivity ──seed/restore/persist──▶ NavigationStateBridge + │ (Activity ↔ singleton seam) + │ setContent + ▼ + HedvigApp ──▶ NavDisplay(backStack = controller.entries, entryProvider = hedvigEntryProvider { … }) +``` + +- **One Metro graph** (`AppScope`). Bindings are contributed from anywhere and merged at compile time. +- **One back stack** — `SnapshotStateList` owned by the app-scoped `BackstackController`. +- **One `NavDisplay`** in `HedvigApp` rendering that list. +- Destinations are `@Serializable` keys (`HedvigNavKey`). The KSP processor makes them survive process death. +- Presenters mutate navigation through the `Backstack` interface; the UI uses the concrete controller. + +--- + +# Part I — Dependency Injection with Metro + +## I.1 One graph, one scope + +There is a single `@DependencyGraph(AppScope::class)` for the whole Android app, declared in `:app`: + +```kotlin +@DependencyGraph(AppScope::class) +internal interface AppGraph : ViewModelGraph { + val workerFactory: MetroWorkerFactory + val hedvigBuildConstants: HedvigBuildConstants + + @Multibinds(allowEmpty = true) + val serializersModules: Set + + fun inject(activity: MainActivity) + fun inject(application: HedvigApplication) + fun inject(service: PushNotificationService) + + @DependencyGraph.Factory + interface Factory { + fun create(@Provides applicationContext: Context): AppGraph + } +} +``` + +`AppScope` itself is deliberately trivial — a marker, nothing more: + +```kotlin +abstract class AppScope private constructor() +``` + +**Decision: no subscoping.** We did *not* introduce per-feature or per-screen scopes. Everything is `AppScope`. The reasons: + +- Subscoping in DI was historically used to bound object lifetimes to a screen. In this app, **screen lifetime is owned by Navigation 3's per-entry `ViewModelStore`**, not by DI. A screen's state lives in its ViewModel (resolved through the Metro ViewModel factory), which Nav3 disposes when the entry leaves the back stack. So DI scoping for screen lifetime would be redundant with the navigation layer. +- A single scope means a binding contributed by any module is visible everywhere, with no "which subgraph am I in" cognitive overhead. +- Compile-time graph validation catches the mistakes subscoping was protecting against. + +If you ever feel you need a narrower scope, first check whether the thing you're scoping is really screen-lived — if so, it belongs in a ViewModel, not a DI subscope. + +## I.2 The contribution model + +Bindings are never listed centrally. Each module *contributes* into `AppScope`, and Metro merges them at compile time. + +| You have… | Use… | Example | +|---|---|---| +| A class whose constructor you control, implementing one interface | `@ContributesBinding(AppScope::class)` on the impl, `@Inject` constructor | repositories, use cases, `AppViewModelFactory` | +| A binding you can't annotate a constructor on (framework type, builder, configured object) | `@Provides` in a `@ContributesTo(AppScope::class) interface` | `ApplicationMetroProviders` (ImageLoader, SharedPreferences, Clock, …) | +| A singleton | add `@SingleIn(AppScope::class)` *wherever the binding is declared* — on the `@Inject` constructor, or on the `@Provides` method when you can't annotate the constructor | `SessionReconciler` (on its `@Inject` constructor); `BackstackController` (on the `@Provides` in `BackstackControllerProviders`, since its constructor takes hand-built state holders — see II.4) | +| Many implementations collected into a set | `@ContributesIntoSet` | `SerializersModule`s, `DeepLinkMatcherProvider`s | +| Many implementations collected into a keyed map | `@ContributesIntoMap` + a `@MapKey` | ViewModels, workers | +| A collection that may legitimately be empty | declare it `@Multibinds(allowEmpty = true)` on the graph | `Set` | +| Runtime args + injected deps | `@AssistedInject` + `@AssistedFactory` | a screen ViewModel taking a `contractId` | + +`ApplicationMetroProviders` is the canonical example of the `@Provides`-in-`@ContributesTo` pattern — every `@Provides` there is `@SingleIn(AppScope::class)` and builds an Android/third-party object the graph needs (Coil `ImageLoader`, Media3 `SimpleCache`, `SharedPreferences`, clocks, the `HedvigDeepLinkMatcher` folded from a multibound set, …). + +**Why this matters:** a feature module adds a binding simply by annotating a class in its own source set. No edit to `:app`, no central registry to keep in sync, no merge conflicts on a god-module. The graph is assembled by the compiler. + +## I.3 ViewModels through Metro + +We use the `metro-viewmodel` integration (`dev.zacsweers.metrox.viewmodel`). The graph implements `ViewModelGraph`, and a single `AppViewModelFactory` fans out to three multibound maps: + +```kotlin +@ContributesBinding(AppScope::class) +@Inject +class AppViewModelFactory( + override val viewModelProviders: Map, () -> ViewModel>, + override val assistedFactoryProviders: Map, () -> ViewModelAssistedFactory>, + override val manualAssistedFactoryProviders: + Map, () -> ManualViewModelAssistedFactory>, +) : MetroViewModelFactory() +``` + +`MainActivity` publishes it into the composition: + +```kotlin +CompositionLocalProvider( + LocalMetroViewModelFactory provides appGraph.metroViewModelFactory, +) { HedvigApp(...) } +``` + +Inside an `entry { }` block you then resolve a ViewModel with one of two helpers: + +```kotlin +// No runtime args — registered with @Inject + @ContributesIntoMap + @ViewModelKey(MyVm::class) +val vm: InsuranceViewModel = metroViewModel() + +// Assisted (navigation) args — registered with the factory pattern below +val vm: ContractDetailViewModel = + assistedMetroViewModel { + create(key.contractId) + } +``` + +A screen taking navigation arguments registers like this (note the three annotations on the factory): + +```kotlin +@AssistedInject +internal class ContractDetailViewModel( + @Assisted contractId: String, + featureManager: FeatureManager, + useCase: GetContractForContractIdUseCase, +) : MoleculeViewModel( + initialState = ContractDetailsUiState.Loading, + presenter = ContractDetailPresenter(contractId, featureManager, useCase), + ) { + @AssistedFactory + @ManualViewModelAssistedFactoryKey + @ContributesIntoMap(AppScope::class) + fun interface Factory : ManualViewModelAssistedFactory { + fun create(@Assisted contractId: String): ContractDetailViewModel + } +} +``` + +**Why "manual" assisted factories.** `@ManualViewModelAssistedFactoryKey` keys the factory by the *factory type* you name at the call site, letting `assistedMetroViewModel` pick exactly the right one. The ViewModel just constructs its Presenter, passing the assisted arg straight through. The Presenter never sees DI — it receives plain typed dependencies, exactly as before the migration. + +**The ViewModel lifetime is the Nav3 entry's `ViewModelStore`.** This is the linchpin that makes the app-scoped back stack safe (see II.4): a ViewModel survives a configuration change as the *same* instance, while the `BackstackController` outlives all of them. + +## I.4 Workers + +WorkManager workers can't be constructed by us directly, so they go through a multibound map and a custom `WorkerFactory`: + +```kotlin +@Inject +class MetroWorkerFactory( + private val factories: Map, ChildWorkerFactory>, +) : WorkerFactory() { + override fun createWorker(appContext, workerClassName, params): ListenableWorker? = + factories.entries.firstOrNull { it.key.java.name == workerClassName }?.value?.create(appContext, params) +} +``` + +A worker contributes an `@AssistedFactory ChildWorkerFactory` keyed with `@WorkerKey(MyWorker::class)` into the map. `:app` exposes `workerFactory` off the graph and installs it in the WorkManager configuration. + +## I.5 Demo mode — the one place we want two implementations + +The app has a demo mode (a fully fake backend for screenshots/onboarding). It is the *only* legitimate reason to have two implementations of the same type and choose between them at runtime: + +```kotlin +fun interface Provider { suspend fun provide(): T } + +interface ProdOrDemoProvider : Provider { + val demoManager: DemoManager + val demoImpl: T + val prodImpl: T + override suspend fun provide(): T = + if (demoManager.isDemoMode().first()) demoImpl else prodImpl +} +``` + +A `ProdOrDemoProvider` is always `@SingleIn(AppScope::class)`. Consumers inject `Provider` and call `.provide()`. + +**Rule:** do not reach for `Provider` to lazily resolve ordinary dependencies. Its sole purpose is the demo/prod swap. If you see `Provider` in a constructor, that `Something` has a demo implementation. + +## I.6 iOS / KMP: the second graph + +The iOS target has its own Metro graph in `:shareddi`: + +```kotlin +@DependencyGraph(AppScope::class) +internal interface IosGraph : ViewModelGraph { + val imageLoader: ImageLoader + @DependencyGraph.Factory + interface Factory { + fun create( + @Provides accessTokenFetcher: AccessTokenFetcher, + @Provides deviceIdFetcher: DeviceIdFetcher, + @Provides featureManager: FeatureManager, + @Provides languageStorage: LanguageStorage, + @Provides appBuildConfig: AppBuildConfig, + ): IosGraph + } +} +``` + +Both graphs target `AppScope`, so the **same `@ContributesBinding`/`@ContributesTo` declarations in shared KMP modules populate both** — that's the whole point of a compile-time, contribution-based graph for KMP. The iOS host provides the platform seams (token fetcher, device id, feature flags, language, build config) via the factory. `metro-viewmodel` is Android-only; iOS resolves its ViewModels through `ViewModelGraph` differently (the iOS Compose UI view controllers wire them directly). + +## I.7 Build wiring + +`hedvig.gradle.plugin` applies Metro to **every** Kotlin module, lazily, after a Kotlin plugin is present (`configureMetro` in `HedvigGradlePlugin.kt`): + +```kotlin +pluginManager.withPlugin(kotlinMultiplatform) { apply(metro) } +pluginManager.withPlugin(kotlinJvm) { apply(metro) } +pluginManager.withPlugin(kotlin) { apply(metro) } +// metro-viewmodel artifacts only on Android modules: +pluginManager.withPlugin("com.android.library") { addMetroViewModelDependencies() } +pluginManager.withPlugin("com.android.application") { addMetroViewModelDependencies() } +``` + +Using `withPlugin` makes us independent of plugin ordering inside each module's `plugins {}` block. The metro-viewmodel runtime is gated on Android plugins so pure-JVM library modules don't drag in Compose. + +**Required flag:** `metro.generateContributionProviders=true` in `gradle.properties`. Without it, contributions aren't materialized into providers and the graph won't compile. + +--- + +# Part II — Navigation 3 and the single back stack + +## II.1 The inversion: we own the back stack + +There is exactly one `NavDisplay`, in `HedvigApp`, and its back stack *is* the controller's list: + +```kotlin +NavDisplay( + backStack = backstackController.entries, // SnapshotStateList + onBack = { backstackController.popBackstack() }, // pops, or finishes the app at the root (see II.3) + entryDecorators = entryDecorators { backstackController.allLiveContentKeys }, + sceneDecoratorStrategies = sceneDecoratorStrategies, + transitionSpec = hedvigTransitionSpec(backstackController, density), + popTransitionSpec = popSpec, + predictivePopTransitionSpec = { popSpec() }, + entryProvider = entryProvider { hedvigEntryProvider(backstack = backstackController, /* … */) }, +) +``` + +`NavDisplay` is a pure renderer of an observable list. There is no `NavController`, no route strings, no `navgraph`/`navdestination`. Mutating `entries` (push/pop) re-renders. + +## II.2 Destinations are keys: `HedvigNavKey` + +```kotlin +interface HedvigNavKey : NavKey +``` + +A destination is a `@Serializable` class or object implementing `HedvigNavKey`. The `@Serializable` is load-bearing — it's how the back stack survives process death (Part III). + +```kotlin +@Serializable +data object InsurancesKey : HedvigNavKey, CrossSellEligibleDestination, TopLevelTabRoot { + override val topLevelTab = TopLevelTab.Insurances +} + +@Serializable +internal data class InsuranceContractDetailKey(val contractId: String) : + HedvigNavKey, DeepLinkAncestry, CrossSellEligibleDestination { + override val owningTab = TopLevelTab.Insurances + override val syntheticParents = emptyList() +} +``` + +### The marker-interface family (in `navigation-common`, KMP `commonMain`) + +This is one of the most important design ideas in the whole migration, so it's worth dwelling on. `:app` needs to reason about destinations — which tab does this belong to? can the cross-sell sheet show here? should we stash the session on logout? — **without depending on the feature module that defines the key** (that would break "features can't depend on features", and `:app` would have to import every feature's internal keys). + +The solution: keys *declare their own capabilities* by implementing small marker interfaces that live in the shared `navigation-common` module. `:app` reasons against the markers, never the concrete key. + +| Marker | Exposes | Meaning | +|---|---|---| +| `TopLevelTabRoot` | `topLevelTab: TopLevelTab` | This key is the root of a bottom-nav tab. Lets the runs model map any tab root back to its tab without naming the feature. | +| `DeepLinkAncestry` | `owningTab`, `syntheticParents: List` | How to rebuild a synthetic back stack when this key is entered alone (e.g. from a notification). | +| `CrossSellEligibleDestination` | — | The cross-sell bottom sheet may appear when this screen is on top. | +| `SuppressesChatPushNotification` | — | A chat push must be suppressed while this screen is shown (the screen already shows the message). | +| `DeliberateLogoutOrigin` | — | Reaching the logged-out state from here is an intentional sign-out — don't stash the session for restore. | + +Note the **same-module invariant** baked into `DeepLinkAncestry.syntheticParents`: a key may only list parents from its *own* feature. The jump *up to a tab* is expressed abstractly via `owningTab`, so no feature ever names another feature's key. This is what keeps the feature-isolation rule intact while still allowing rich synthetic back stacks. + +## II.3 The `Backstack` seam: interface for Presenters, concrete for `:app` + +`navigation-compose` defines a thin interface: + +```kotlin +@Stable +interface Backstack { + val entries: MutableList + fun navigateUp(): Boolean = popBackstack() // default: plain pop; :app overrides it + fun popBackstack(): Boolean = Snapshot.withMutableSnapshot { /* pop if size > 1, else false */ } +} + +fun Backstack.add(key: HedvigNavKey): Boolean = entries.add(key) +inline fun Backstack.popUpTo(inclusive: Boolean) { … } +inline fun Backstack.navigateAndPopUpTo(key, inclusive) { … } +inline fun Backstack.findLastOrNull(): T? = … +inline fun Backstack.removeAllOf() { … } +``` + +`entries` is public and is the single source of truth; the helpers exist so navigation operations are discoverable on a dedicated type rather than drowning in the full `MutableList` API. Features can add their own flow-specific extensions on the same receiver. + +**Both `navigateUp` and `popBackstack` are interface methods, not extensions, precisely so `:app`'s controller can override them.** The defaults are pure list operations (`popBackstack` returns `false` at the root, no side effect) — that keeps the KMP module Android-free and lets the `commonTest` `TestBackstack` exercise them. `BackstackController` overrides `popBackstack` so that a pop at the root **finishes the Activity** (Back/close from the last screen exits the app instead of silently no-opping). `navigateUp`'s override deliberately falls through to the *pure* `super.popBackstack()`, so an Up press at the root stays a no-op and never exits — Up is not Back. A caller that wants a different at-root outcome (e.g. termination-success reached as a lone deep link routes to the Insurances tab) must branch on `entries` itself, because in `:app` the root case no longer returns `false` from `popBackstack` — it finishes. + +**Why a narrow interface for Presenters.** Presenters (Molecule) are long-lived. They get the `Backstack` interface, which exposes navigation verbs but hides the controller's tab-switching / login-stash internals. The concrete `BackstackController` is handed only to `:app` and `HedvigApp`, which legitimately need the full surface. + +## II.4 The central decision: an app-scoped controller, not composition state + +> This is the single most important "why" in the navigation rewrite. Read it carefully before changing anything in `BackstackController`. + +In the old world, navigation state lived in `rememberSerializable` inside `MainActivity.setContent`. That worked for a `NavController` because composables read it directly. But we drive navigation from **Presenters**, which are hosted in ViewModels. + +Here's the lifetime mismatch that breaks the naive approach: + +- A **Metro ViewModel survives a configuration change** as the *same* instance (its `ViewModelStore` outlives Activity recreation). +- **Composition-scoped state does not** — on rotation, the Activity is recreated, `setContent` re-runs, and `rememberSerializable` deserializes the state into a **brand-new object**. + +So if a long-lived Presenter captured a reference to the composition-scoped back stack, that reference would go **stale on the very next rotation** — the Presenter would be mutating a dead copy while the UI rendered a different one. + +**The fix:** hoist navigation state into an **app-scoped Metro singleton** (`BackstackController`), so it outlives every ViewModel and every Activity instance. A Presenter can hold the `Backstack` reference for its whole life and always mutate the live, rendered stack. + +```kotlin +@Stable +internal class BackstackController( + override val entries: SnapshotStateList, + internal val parkedRuns: SnapshotStateMap>, + pendingDeepLinkState: MutableState, + stashedSessionState: MutableState, + var isOwnTask: () -> Boolean = { true }, + var escapeToOwnTask: (List) -> Unit = {}, +) : Backstack { … } + +@ContributesTo(AppScope::class) +internal interface BackstackControllerProviders { + @Provides @SingleIn(AppScope::class) + fun provideBackstackController(): BackstackController = + BackstackController(mutableStateListOf(), mutableStateMapOf(), mutableStateOf(null), mutableStateOf(null)) + + @Provides + fun bindBackstack(controller: BackstackController): Backstack = controller +} +``` + +The four state holders are created once and owned by the singleton, so they survive configuration changes while remaining the live objects the UI renders. The same singleton is exposed two ways: as `BackstackController` to `:app`, and as `Backstack` to feature Presenters. + +**The `isOwnTask` / `escapeToOwnTask` / `finishApp` hooks are Activity-bound and attached in `onCreate`, never in the constructor.** The controller is an app-singleton that outlives any single Activity; capturing an Activity-bound lambda at construction would be the *exact* stale-reference leak we set out to avoid. They default to in-process behaviour (and `finishApp` to a no-op) so unit tests need no Activity. + +## II.5 Registering destinations: `hedvigEntryProvider` and cross-feature lambdas + +Every feature exposes a `fun EntryProviderScope.featureEntries(...)` that registers its screens with `entry { }`. `:app`'s `hedvigEntryProvider` calls all of them, decomposed into readable per-domain sub-builders (`addHomeEntries`, `addInsuranceEntries`, `addProfileEntries`, `addSharedFlowEntries`, …). + +```kotlin +internal fun EntryProviderScope.hedvigEntryProvider( + backstack: BackstackController, scope: CoroutineScope, windowSizeClass: WindowSizeClass, … +) { + val navigateToNewConversation: () -> Unit = { backstack.add(ChatKey(Uuid.randomUUID().toString())) } + val navigateToConnectPayment: () -> Unit = { backstack.add(TrustlyKey) } + // … cross-feature navigation closures derived ONCE here … + + addHomeEntries(backstack, …, navigateToNewConversation, navigateToConnectPayment, …) + addInsuranceEntries(backstack, …, navigateToNewConversation, navigateToMovingFlow, …) + // … +} +``` + +**Why all wiring lives in `:app`.** A feature's `*Key`s live in the feature *impl* module, and the build rule blocks one feature from referencing another's key. So the only place that can legally know "Insurances → start a chat conversation" is `:app`. The pattern: `:app` derives a `navigateToX` lambda once and threads it down to whichever feature entries need it. Features expose *callbacks*, never imports of sibling keys. + +Inside an entry, the ViewModel is resolved with `metroViewModel()` / `assistedMetroViewModel(...)` and the destination composable is rendered. `dropUnlessResumed { }` guards navigation callbacks so a double-tap or a click during a transition can't push twice. + +## II.6 `feature-x-navigation` modules and the enforced carve-out + +Sometimes a feature genuinely needs to navigate to *another* feature's entry point (e.g. anywhere → chat). The keys that are meant to be reachable cross-feature live in a dedicated `feature-{name}-navigation` module containing **only** those public `@Serializable` keys. Everything else — entries, screens, internal keys — stays private in the owning feature. + +The feature-isolation rule has a precise carve-out for exactly these modules (`configureFeatureModuleGuidelines` in `HedvigGradlePlugin.kt`): + +```kotlin +fun String.isFeatureModule() = startsWith("feature-") && !startsWith("feature-flags") +fun String.isFeatureNavigationModule() = isFeatureModule() && endsWith("-navigation") + +// In the resolution strategy: a dependency on another feature module fails the build, +// UNLESS the requested module is a `-navigation` module. +if (requested.name.isFeatureNavigationModule()) return@eachDependency // allowed +require(!requested.name.isFeatureModule()) { "…feature cannot depend on feature…" } +``` + +So: depend on `feature-chat-navigation` to get `ChatKey`; you still cannot depend on `feature-chat`. + +## II.7 Multiple back stacks: the "runs model" + +Each bottom-nav tab needs its own back stack with full state retention. We model this without N separate `NavController`s using a single list plus a parked map. The logic is split between `BackstackController` (mutations) and `TopLevelRunLogic.kt` (pure list functions). + +**Invariant: Home is always the base.** `entries[0]` is always `HomeKey` (when logged in) or `LoginKey` (when logged out). Home's "run" (Home plus any drill-down screens) is always present at the bottom of `entries`. A side tab, when active, sits *on top of* Home's run. + +**Parking.** When you switch away from a side tab, its run (the side-tab root plus its drill-downs) is lifted out of `entries` into `parkedRuns: SnapshotStateMap>`. When you switch back, it's restored. Home is never parked — it stays in the rendered stack. + +```kotlin +fun selectTopLevel(topLevelTab: TopLevelTab) = Snapshot.withMutableSnapshot { + if (topLevelTab == currentTopLevel) { // re-tap current tab → pop its run to root + entries.replaceWith(popTopRunToStart(entries)); return@withMutableSnapshot + } + val leavingSideTab = nearestTopLevelTab(entries)?.takeIf { it != TopLevelTab.Home } + val homeRun = collapseToHome(entries) + if (leavingSideTab != null) parkedRuns[leavingSideTab] = activeSideRun(entries) + val restored = if (topLevelTab == TopLevelTab.Home) homeRun + else homeRun + (parkedRuns.remove(topLevelTab) ?: listOf(topLevelTab.startDestination)) + entries.replaceWith(restored) +} +``` + +The pure helpers (`collapseToHome`, `activeSideRun`, `popTopRunToStart`, `nearestTopLevelTab`) operate on a plain `List` and are trivially unit-testable. + +## II.8 Keeping parked state alive: retained decorators and `allLiveContentKeys` + +A parked tab's run is no longer in `entries`, so by default Nav3 would dispose its saved state and ViewModels. We don't want that — switching back to a tab should restore it exactly. Two custom `NavEntryDecorator`s solve this: + +```kotlin +@Composable +fun entryDecorators(retainedContentKeys: () -> Set): List> = listOf( + rememberRetainedSaveableStateHolderNavEntryDecorator(retainedContentKeys), + rememberRetainedViewModelStoreNavEntryDecorator(retainedContentKeys), +) +``` + +They consult `retainedContentKeys` — wired to `backstackController.allLiveContentKeys` — when deciding whether to dispose a key's state on pop: + +```kotlin +val allLiveContentKeys: Set get() = buildSet { + entries.forEach { add(it.toString()) } + parkedRuns.values.forEach { run -> run.forEach { add(it.toString()) } } +} +``` + +A key that merely *moved into* `parkedRuns` is still "live", so its saved state and ViewModel are kept. A key actually removed (popped from both) is disposed. Note `StashedSession` (Part IV) is deliberately **excluded** from this set, so a stashed logged-out session has all its per-entry state disposed while it waits. + +## II.9 Chrome (the nav bar/rail) as a Scene decorator + +The persistent bottom bar / side rail is rendered *inside* the scene via a `SceneDecoratorStrategy` (`NavSuiteSceneDecorator`), not by wrapping `NavDisplay` in an outer `Row/Column { chrome; content }`. This is the purpose-built Nav3 way to add persistent chrome that rides inside the top-level `AnimatedContent`, so it stays visually static while content transitions. + +Chrome is opt-in per destination: only scenes whose metadata carries `NavSuiteSceneDecoratorStrategy.showNavBar()` (the tab roots plus the handful of deeper screens that keep the bar) get wrapped; everything else is full-screen. + +A lone deep link is a special case. The runs-model invariant (`entries[0] == HomeKey`) is briefly broken when a deep link stands alone, so showing the rail would expose that. The controller classifies the situation and the decorator obeys: + +```kotlin +enum class LoneDeepLinkChrome { ShowSuite, ShowUpBar, ShowNothing } + +val loneDeepLinkChrome: LoneDeepLinkChrome get() { + val first = entries.firstOrNull() + val isAlone = entries.size == 1 && first !is HomeKey && first !is LoginKey + return when { + !isAlone -> LoneDeepLinkChrome.ShowSuite // normal: rail + first?.topLevelTabOrNull() != null -> LoneDeepLinkChrome.ShowUpBar // lone tab root: decorator supplies Up + else -> LoneDeepLinkChrome.ShowNothing // deep screen renders its own Up + } +} +``` + +## II.10 Transitions: fade between tabs, slide within one + +`hedvigTransitionSpec` decides fade-through vs slide by asking the controller which tab the outgoing and incoming top keys belong to: + +```kotlin +val fromTab = backstack.owningTopLevelTabForContentKey(initialState.entries.lastOrNull()?.contentKey) +val toTab = backstack.owningTopLevelTabForContentKey(targetState.entries.lastOrNull()?.contentKey) +if (shouldFadeThrough(fromTab, toTab)) /* fade */ else /* slide */ +``` + +`shouldFadeThrough(from, to)` is true only when both are non-null and differ — a tab change. A transition to/from a tab-less screen (e.g. Login) is never a tab change. + +**Why ownership is positional, not per-key.** A screen's owning tab depends on *which run it sits in*, which can't be read from a single key in isolation (restoring a parked side-tab run lands on a deep screen whose key alone doesn't name a tab). `owningTopLevelTabForContentKey` resolves it from the full rendered stack plus all parked runs, and memoizes into `owningTabByContentKey`. That map **accumulates and is never cleared**: a just-popped key keeps its last-known owner so the *outgoing* scene of the pop can still be classified. A key type only ever lives in one tab's run, so a retained owner can't go stale. + +--- + +# Part III — State persistence and the Activity seam + +## III.1 The problem + +An in-memory singleton is wiped when the process dies. So the app-scoped `BackstackController` needs help to survive **process death** (but not configuration changes — on a config change the singleton is still alive and must be reused untouched). + +## III.2 `NavigationStateBridge` — the single seam + +All crossing of the "Activity lifecycle ↔ app-scoped singleton" boundary is funneled through one stateless object, `NavigationStateBridge`, so the decision about what the stack should be at launch is made in exactly one place: + +```kotlin +fun restoreAndPersist( + backstackController: BackstackController, + savedStateRegistry: SavedStateRegistry, + intent: Intent, + isColdStart: Boolean, // == (savedInstanceState == null) + serializersModules: Set, +) { + val config = SavedStateConfiguration { serializersModule = serializersModules.merge() } + + // Seeding precedence, top to bottom: + val handoff = if (isColdStart) readEscapeToOwnTaskHandoff(intent, serializersModules) else null + if (!handoff.isNullOrEmpty()) { + backstackController.reseed(handoff) // 1. explicit re-root + } else { + savedStateRegistry.consumeRestoredStateForKey(NAV_STATE_REGISTRY_KEY) // 2. process-death restore + ?.let { decodeFromSavedState(NavStateSnapshot.serializer(), it, config) } + ?.let { backstackController.restoreFromSavedState(it.entries, it.parkedRuns, it.pendingDeepLink, it.stashedSession) } + backstackController.seedIfEmpty(listOf(LoginKey)) // 3. guarantee a root + } + + // Persist on save — provider reads whatever the singleton holds at save time: + savedStateRegistry.registerSavedStateProvider(NAV_STATE_REGISTRY_KEY) { + encodeToSavedState(NavStateSnapshot.serializer(), NavStateSnapshot( + backstackController.entries.toList(), backstackController.parkedRuns.toMap(), + backstackController.pendingDeepLink, backstackController.stashedSession), config) + } +} +``` + +Key points: + +- **`restoreFromSavedState` and `seedIfEmpty` are both no-ops when the live state is already populated.** On a configuration change the singleton is alive and populated, so the serialized snapshot is ignored and the *live* state always wins. Only on a genuine cold start (empty singleton) does the snapshot re-hydrate it. +- The whole hoisted state is captured in `NavStateSnapshot` (entries + parkedRuns + pendingDeepLink + stashedSession), mirroring the four holders. +- The persist provider serializes at save time, so a navigation driven by a Presenter milliseconds before the process is killed is captured. + +## III.3 The KSP processor: polymorphic serializers without boilerplate + +The back stack is `List<@Polymorphic HedvigNavKey>`. To (de)serialize it, kotlinx.serialization needs every concrete subtype registered polymorphically. The subtypes live across ~25 feature modules, and we don't use JVM reflection (it doesn't exist on iOS). Hand-writing a `SerializersModule { polymorphic(HedvigNavKey::class) { subclass(...) } }` per module would be error-prone boilerplate that silently rots when someone forgets to add a new key. + +So `:navigation-keys-processor` (a KSP `SymbolProcessor`) generates it. For every concrete `@Serializable` type implementing `HedvigNavKey` in a module, it emits one provider interface per package: + +```kotlin +@ContributesTo(AppScope::class) +interface FeatureFoo…GeneratedNavKeySerializersModuleProvider { + @Provides @IntoSet + fun provide…NavKeySerializersModule(): SerializersModule = SerializersModule { + polymorphic(HedvigNavKey::class) { + subclass(FooKey::class) + subclass(BarKey::class) + } + } +} +``` + +Metro merges every contributed module into the multibound `Set`; `:app` folds it with `merge()` and feeds it into the `SavedStateConfiguration` and the handoff `Json`. + +**The unique-name problem and its solution.** Every generated interface is merged into one graph, so its name and provide-method name must be globally unique or Metro sees a duplicate-type / same-signature diamond. The package alone isn't enough — a feature and its `feature-x-navigation` sister legitimately share a package (`.navigation`). The processor folds the **set of key FQNs in this compilation** into the name via a dependency-free **FNV-1a 32-bit hash**: + +```kotlin +val uniqueToken = "${packageName.toUniqueToken()}_${keyFqns.joinToString("\n").stableHash()}" +``` + +A nav-key class is declared in exactly one module and KSP only processes the current compilation's declarations, so this module's key set is globally unique by construction. The hash is deterministic across machines/JVMs (no salt), and changes only when the module's key set changes — which already regenerates the file. + +**Opt-in via `hedvig { navKeys() }`.** A module wires the processor by calling `navKeys()` in its `hedvig {}` block (`NavKeysHandler` attaches the KSP dep to `ksp` for plain Android or `kspAndroid` for KMP — the Android target's KSP sees `commonMain` symbols too, so one pass covers main/androidMain/commonMain and avoids double-emission). + +> **The single most common new-key bug:** you add a `@Serializable HedvigNavKey` but the module is missing `navKeys()`. It compiles and runs fine until process-death restore, then crashes with a missing polymorphic serializer. `ExhaustiveBackStackSerializationTest` exists to catch exactly this. + +--- + +# Part IV — Sessions, auth, and the splash gate + +## IV.1 `SessionReconciler` + +Auth↔back-stack reconciliation used to be loose effects in `HedvigApp`. It now lives in one app-scoped class with two narrowly-scoped jobs: + +```kotlin +@SingleIn(AppScope::class) @Inject +internal class SessionReconciler( + private val backstackController: BackstackController, + private val authTokenService: AuthTokenService, + private val demoManager: DemoManager, + private val memberIdService: MemberIdService, +) { + val isReady: StateFlow // gates the splash + + suspend fun reconcile() { … } // 1. cold-start start-scene resolution + suspend fun observeForcedLogout(lifecycle: Lifecycle) { … } // 2. runtime root honesty +} +``` + +**1. Start-scene resolution gates the splash.** `MainActivity` keeps the splash up while `!sessionReconciler.isReady.value`. `reconcile()` resolves whether the first frame should be Home (logged in / demo) or Login *before* the splash is dismissed, so we never flash the seeded Login root and then jump to Home. It uses Arrow `raceN` of "auth says LoggedIn" vs "demo mode on", and only mutates the root if it disagrees with the already-restored stack — so a process-death restore that already reflects the previous session is left untouched. + +**2. Forced logout while running.** `observeForcedLogout` is lifecycle-gated to `STARTED`: it tracks the latest member id and logs out if we leave demo mode without valid tokens. + +The reconciler is deliberately narrow — *only* the auth-driven root. Deep links, notifications, and everything else live in their own observers. The `BackstackController` is injected (app-scoped); only the `Lifecycle` is passed per-call by the composition, keeping the reconciler free of any Android/Compose lifetime. + +## IV.2 Login/logout stashing + +Logout doesn't just drop to Login — it can **stash** the session so a same-member re-login restores their place: + +```kotlin +fun setLoggedOut(memberId: String?) = Snapshot.withMutableSnapshot { + val isDeliberateLogout = entries.lastOrNull() is DeliberateLogoutOrigin + stashedSession = if (memberId != null && !isDeliberateLogout) + StashedSession(memberId, entries.toList(), parkedRuns.toMap()) else null + parkedRuns.clear(); entries.replaceWith(listOf(LoginKey)) +} + +fun setLoggedIn(memberId: String?) = Snapshot.withMutableSnapshot { + val pending = pendingDeepLink; pendingDeepLink = null + val stash = stashedSession?.takeIf { memberId != null && it.memberId == memberId }; stashedSession = null + parkedRuns.clear() + when { + pending != null -> entries.replaceWith(listOf(pending)) // deep link lands alone + stash != null -> { parkedRuns.putAll(stash.parkedRuns); entries.replaceWith(stash.entries) } + else -> entries.replaceWith(listOf(HomeKey)) // fresh Home + } +} +``` + +Rules encoded here: + +- A stash is tagged with the `memberId` (JWT `sub`). It's only restored if the next login is the **same member**. Otherwise it's dropped — a stash can never bleed into a different member's session. +- `memberId == null` (demo / unknown identity) stashes nothing — that session can never be safely restored. +- A `DeliberateLogoutOrigin` top screen (e.g. Profile → Log out) means "log me out now"; we don't stash even with a known member. +- `StashedSession` is **excluded from `allLiveContentKeys`**, so the retained decorators dispose every per-entry state (ViewModels, saved state) while it waits. On restore, history comes back but state is freshly recreated. +- A `pendingDeepLink` takes precedence over a stash and lands **alone** (re-enabling the runs model on the next Up via the synthetic stack). + +--- + +# Part V — Deep links and task management + +## V.1 Matching + +Each feature builds `DeepLinkMatcher`s from its `HedvigDeepLinkContainer` URI patterns and contributes a `DeepLinkMatcherProvider` (`@ContributesIntoSet`). `ApplicationMetroProviders` folds the multibound set into one `HedvigDeepLinkMatcher`, which returns the highest-priority matching `HedvigNavKey` (or null → open in a browser). A matcher whose key has a required argument *throws* when the arg is absent rather than returning null, so the aggregator treats any throw as a non-match. + +## V.2 From Intent to navigation + +`MainActivity` forwards `ACTION_VIEW` intents as raw URI strings down an unlimited `deepLinkChannel`. `HedvigApp` collects them and routes each through the matcher once the member is logged in. This replaces Nav2's automatic launch-intent deep-link handling on the (now removed) `NavController`. + +```kotlin +fun navigateToDeepLink(key: HedvigNavKey) { + if (!isLoggedIn) { pendingDeepLink = key; return } // held; landed by setLoggedIn + Snapshot.withMutableSnapshot { entries.remove(key); entries.add(key) } // dedup + append (Nav2 parity) +} +``` + +Logged in, a deep link **joins the current task** (dedup then append). Logged out, it's held as `pendingDeepLink` (persisted across rotation / process death — e.g. mid-OTP) and landed alone after login. + +## V.3 Synthetic back stacks and task-aware Up + +When a deep link is entered alone, Back/Up must behave as if the user had navigated there inside the app. `syntheticStackFor` rebuilds the ancestry, reusing the abstract `DeepLinkAncestry` markers so no feature names another's key: + +```kotlin +internal fun syntheticStackFor(key: HedvigNavKey): List { + val ancestry = key as? DeepLinkAncestry + val tab = ancestry?.owningTab ?: TopLevelTab.Home + return buildList { + add(HomeKey) + if (tab != TopLevelTab.Home) add(tab.startDestination) + addAll(ancestry?.syntheticParents.orEmpty()) + add(key) + }.distinct() +} +``` + +`navigateUp` is task-aware: + +```kotlin +override fun navigateUp(): Boolean { + val top = entries.lastOrNull() ?: return false + val synthetic = syntheticStackFor(top) + if (entries.size == 1 && synthetic.size > 1) { // lone deep link with real ancestry + val parentStack = synthetic.dropLast(1) + if (isOwnTask()) entries.replaceWith(parentStack) // materialize in place + else escapeToOwnTask(parentStack) // re-root into our own task + return true + } + return super.popBackstack() // everywhere else: PURE pop (no app-finish) +} + +override fun popBackstack(): Boolean { // Back / close + val popped = super.popBackstack() + if (!popped) finishApp() // at the root → exit the app + return popped +} +``` + +`navigateUp` falls through to `super.popBackstack()` (the pure interface default) on purpose: an Up press that can't go up is a no-op, whereas a Back press at the root exits the app. The `finishApp` lambda is an Activity-bound hook attached in `onCreate` alongside `isOwnTask` / `escapeToOwnTask` (same stale-reference reasoning), and is a no-op by default so unit tests never try to finish. + +## V.4 Escaping a foreign task + +If we were launched into the *caller's* task by an external deep link (`isTaskRoot == false`, so `isOwnTask()` is false), pressing Up shouldn't rebuild our ancestry hosted under the foreign app. Instead we relaunch in our own task: + +```kotlin +fun escapeToOwnTask(activity, parentStack, serializersModules) { + val relaunch = Intent(activity, MainActivity::class.java).apply { + addFlags(FLAG_ACTIVITY_NEW_TASK or FLAG_ACTIVITY_CLEAR_TASK) + putExtra(EXTRA_RESTORE_STACK, json(serializersModules).encodeToString(handoffSerializer, parentStack)) + } + activity.finish(); activity.startActivity(relaunch) +} +``` + +The fresh instance reads the ancestry back in `restoreAndPersist` via the **same extra key and codec**, so the write/read contract can't drift. `reseed` clears parked runs / pending deep link / stash, because an escape relaunches with `CLEAR_TASK` while the singleton may survive — old-task state must not bleed into the new root. + +--- + +# Part VI — Invariants, decisions, and tests + +## VI.1 Invariants you must not break + +1. **`entries` is never empty.** There is always at least a root (`LoginKey` or `HomeKey`). The pure pop refuses to remove the last entry; `BackstackController.popBackstack` finishes the Activity in that case instead, so Back/close from the root exits the app rather than emptying the stack. +2. **`entries[0]` is `HomeKey` (logged in) or `LoginKey` (logged out)** — except the transient lone-deep-link state, which is exactly why `loneDeepLinkChrome` exists. +3. **Home is never parked.** Only side tabs go into `parkedRuns`. +4. **Every `@Serializable HedvigNavKey` must be registered for polymorphic serialization** → its module needs `navKeys()`. +5. **A `DeepLinkAncestry.syntheticParents` list contains only the key's own feature's keys.** Cross-tab jumps go through `owningTab`. +6. **Features never import another feature's keys.** Cross-feature navigation is a `navigateToX` lambda from `:app`, or a dependency on a `-navigation` module. +7. **No Activity-bound reference is captured in the `BackstackController` constructor.** Activity hooks are attached in `onCreate` and re-attached on recreation. +8. **GraphQL `octopus.*` types never appear in public APIs** (see CLAUDE.md data-layer rule). + +## VI.2 Tests that guard the architecture + +- **`ExhaustiveBackStackSerializationTest`** (`:app`) — round-trips every `HedvigNavKey` through the merged serializers module. This is the safety net for invariant #4: forget `navKeys()` on a module and this test fails instead of users hitting a process-death crash. +- **`BackstackTest`** (`navigation-compose`) — the `Backstack` helper extensions. +- **`HedvigDeepLinkMatcherTest`** — matching/priority/throw-as-non-match behaviour. +- **`HedvigNavKeySavedStateTest`** (`navigation-common`) — key saved-state behaviour. +- The `TopLevelRunLogic` helpers are pure list functions, unit-testable without a controller. + +## VI.3 Decisions, restated + +| Decision | Why | Rejected alternative | +|---|---|---| +| Single `AppScope`, no subscoping | Screen lifetime is owned by Nav3 ViewModelStores, not DI | Per-screen/per-feature DI scopes (redundant) | +| App-scoped `BackstackController` | Long-lived Presenters need a stable reference; composition state goes stale on rotation | `rememberSerializable` in `setContent` | +| Markers in `navigation-common` | `:app` reasons about keys without depending on features | `:app` importing every feature's keys | +| `-navigation` modules | Allow targeted cross-feature navigation while keeping isolation | Relaxing the feature-isolation rule globally | +| Runs model (one list + parked map) | Multiple back stacks with retention, all serializable as one snapshot | N `NavController`s / N back stacks | +| KSP-generated serializer registrations | No hand-written boilerplate, no reflection (iOS-safe), can't silently rot | Per-module hand-written `SerializersModule` | +| `NavigationStateBridge` as the only seam | One place owns launch-time stack decisions | Restore logic spread across `MainActivity` | +| Chrome via `SceneDecoratorStrategy` | Bar rides inside `AnimatedContent`, stays static during transitions | Outer `Row/Column { chrome; content }` | + +--- + +# Part VII — How to do common things + +**Add a screen.** Define `@Serializable FooKey : HedvigNavKey` (add markers as needed) → register `entry { }` in the feature's entries function → resolve the VM with `metroViewModel()`/`assistedMetroViewModel(...)` → ensure the module has `navKeys()`. + +**Navigate to it from the same feature.** `backstack.add(FooKey(...))` (or `popUpTo`, `navigateAndPopUpTo`, …). + +**Navigate to it from another feature.** Put `FooKey` in `feature-foo-navigation`; in `:app`, derive a `navigateToFoo` lambda and thread it to the calling feature's entries function. The calling feature exposes a callback, never imports `FooKey`. + +**Make it deep-linkable.** Add patterns to the feature's `HedvigDeepLinkContainer`, build matchers via `uriDeepLinkMatchers`, contribute a `DeepLinkMatcherProvider` (`@ContributesIntoSet`). If it can be entered alone, implement `DeepLinkAncestry`. + +**Make it a tab root.** Implement `TopLevelTabRoot`; add the tab to `TopLevelTab`; map its start destination in `startDestination`. + +**Add a binding.** Annotate the impl `@ContributesBinding(AppScope::class)` + `@Inject` constructor. Framework/configured object → `@Provides` in a `@ContributesTo(AppScope::class)` interface. Singleton → add `@SingleIn(AppScope::class)`. + +**Add a ViewModel.** No args: `@Inject` + `@ContributesIntoMap(AppScope::class)` + `@ViewModelKey(Vm::class)`. With nav args: the `@AssistedInject` + `@AssistedFactory @ManualViewModelAssistedFactoryKey @ContributesIntoMap` pattern in I.3. + +**Add a demo-mode-aware dependency.** Make a `ProdOrDemoProvider` (`@SingleIn`), inject `Provider`, call `.provide()`. + +**Add a worker.** `@AssistedFactory ChildWorkerFactory` keyed `@WorkerKey(W::class)`, `@ContributesIntoMap`. + +--- + +## Glossary + +- **AppScope** — the single Metro scope for the whole app graph. A bare marker class. +- **Contribution** — a binding declared in any module (`@ContributesBinding`/`@ContributesTo`/`@ContributesIntoSet`/`@ContributesIntoMap`) merged into the graph at compile time. +- **HedvigNavKey** — `interface HedvigNavKey : NavKey`; a serializable destination identity. +- **Backstack** — the narrow interface Presenters use; backed by the concrete `BackstackController`. +- **BackstackController** — app-scoped singleton owning `entries`, `parkedRuns`, `pendingDeepLink`, `stashedSession`, plus tab/login/deep-link logic. +- **Run** — a tab's slice of the back stack: its root key plus drill-down screens. +- **Parked run** — a side tab's run lifted out of `entries` into `parkedRuns` while another tab is active. +- **NavigationStateBridge** — the stateless object that seeds/restores/persists nav state across the Activity↔singleton boundary and performs the escape-to-own-task handoff. +- **SessionReconciler** — app-scoped; resolves the start scene (gating the splash) and enforces forced logout. +- **StashedSession** — a logged-out session held for same-member restore; excluded from the live-content set. +- **navKeys()** — the `hedvig {}` DSL call that wires the KSP processor generating serializer registrations. +- **metroViewModel / assistedMetroViewModel** — composable helpers resolving a ViewModel through `AppViewModelFactory` via `LocalMetroViewModelFactory`. +- **Provider / ProdOrDemoProvider** — the demo/prod implementation swap. + +--- + +*If something here disagrees with the code, the code wins — update this doc. The point of writing it down was to make the reasoning survivable; keep it alive.*