From 439695b33cc8d8506006cff3a299db0d414a2865 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 28 Aug 2026 11:30:02 -0400 Subject: [PATCH 1/2] feat(tipping): let a user set their own minimum tip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "You" tab's profile checklist has carried a minimum-tip row that did nothing, because there was no way to set one — the amount came from server-supplied regional presets and the row never completed. The contract has since gained SetMinDmChatInitFee and the profile carries min_dm_chat_init_fee, so the row now opens an amount entry and closes once a fee is stored. The same screen is reachable from My Account, where the fee is a standing setting rather than a setup step. The entry point only decides how the confirm button reads: "Next" from the checklist, "Save" from My Account. Save stays inert until the entry differs from what is stored, so opening a prefilled screen and leaving it alone cannot re-submit the same amount. Two changes to AmountEntryDelegate carry that: - `confirmEnabled`, an extra gate ANDed with "something was entered". Every existing caller defaults it to always-allowed. - With a minimum and no maximum, the floor now shows as a standing Info hint instead of appearing only once the entry breaks it. The five existing callers all pass a maximum, so the branch is new-flow only. ProfileController.setMinDmChatInitFee also merges the saved fee into the local profile, which its siblings (setDisplayName, setUsername, setProfilePicture) already did. Without it the checklist row and the My Account row would not reflect a save until the next profile refresh. Node 9553:113241 asks that abandoning a change discards it; there is no draft to persist, so that falls out of the screen having no saved state. --- .../ui/navigation/AppScreenContent.kt | 3 + .../app/core/ui/onboarding/NewUserTutorial.kt | 6 +- .../kotlin/com/flipcash/app/core/AppRoute.kt | 16 ++ .../core/src/main/res/values/strings.xml | 11 + .../app/menu/internal/MenuScreenContent.kt | 9 +- .../app/menu/internal/MenuScreenViewModel.kt | 16 ++ .../app/menu/internal/ProfileTutorial.kt | 5 +- .../app/menu/internal/ProfileTutorialTest.kt | 21 +- .../flipcash/app/myaccount/MyAccountScreen.kt | 8 + .../internal/myaccount/MyAccountMenuItems.kt | 14 ++ .../myaccount/MyAccountScreenViewModel.kt | 11 + .../MyAccountScreenViewModelStateTest.kt | 17 +- .../features/tipping/build.gradle.kts | 1 + .../app/tipping/SetMinimumTipScreen.kt | 48 ++++ .../internal/SetMinimumTipViewModel.kt | 220 ++++++++++++++++++ .../internal/SetMinimumTipStateTest.kt | 72 ++++++ .../shared/amountentry/AmountEntryDelegate.kt | 34 ++- .../amountentry/AmountEntryDelegateTest.kt | 48 ++++ .../services/controllers/ProfileController.kt | 1 + 19 files changed, 540 insertions(+), 21 deletions(-) create mode 100644 apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/SetMinimumTipScreen.kt create mode 100644 apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/SetMinimumTipViewModel.kt create mode 100644 apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/SetMinimumTipStateTest.kt diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt index bcd30a713..27dff4248 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt @@ -26,6 +26,7 @@ import com.flipcash.app.cash.CashScreen import com.flipcash.app.contact.verification.VerificationFlowScreen import com.flipcash.app.currencycreator.CurrencyCreatorFlowScreen import com.flipcash.app.tipping.NewChatScreen +import com.flipcash.app.tipping.SetMinimumTipScreen import com.flipcash.app.tipping.TipAmountEntryScreen import com.flipcash.app.tipping.TippingFlowScreen import com.flipcash.shared.transactionhistory.ActivityHistoryScreen @@ -134,6 +135,8 @@ fun appEntryProvider( } // User Profile Management + annotatedEntry { key -> SetMinimumTipScreen(key.source) } + annotatedEntry { key -> UpdateUserProfileFlowScreen(route = key, resultStateRegistry = resultStateRegistry) } diff --git a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/onboarding/NewUserTutorial.kt b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/onboarding/NewUserTutorial.kt index 9e9eeeb80..633ecbeae 100644 --- a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/onboarding/NewUserTutorial.kt +++ b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/onboarding/NewUserTutorial.kt @@ -85,10 +85,8 @@ sealed interface TutorialItem { } /** - * Drawn but inert. Nothing backs a user-set minimum tip yet: the amount comes from - * server-supplied regional presets, no field for it exists on the profile or the tip-card - * customization message, and iOS has no implementation either. The row is in the design, so - * it is drawn — and it never completes, which is the state node 9641:17019 shows. + * The fee another user has to pay to open a DM, stored on the profile as `minDmChatInitFee`. + * Completes once one is set; the row stays outstanding while the server default applies. */ class MinimumTip(override val isCompleted: Boolean = false) : Profile { override val title: String diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt index abde127e0..d59cbd2c8 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt @@ -161,6 +161,15 @@ sealed interface AppRoute : NavKey, Parcelable { get() = buildUpdateUserProfileStack(includeName, includeUsername, includePhoto) } + /** + * Minimum-tip entry — the fee another user has to pay to open a DM with you, which the profile + * carries as `minDmChatInitFee`. Reached from the You tab's profile checklist and from My + * Account; [source] only decides how the confirm button reads. + */ + @Serializable + @Parcelize + data class SetMinimumTip(val source: MinimumTipSource) : AppRoute + @Serializable @Parcelize sealed interface Sheets : AppRoute { @@ -384,3 +393,10 @@ private fun buildUpdateUserProfileStack( /** Where a display-name entry flow was launched from. Reported as the `Source` analytics property. */ @Serializable enum class DisplayNameSource { Onboarding, MyAccount, TipCardSetup } + +/** + * Where a minimum-tip entry was launched from. The checklist row is a setup step, so its button + * reads "Next"; My Account is editing a setting that already exists, so its button reads "Save". + */ +@Serializable +enum class MinimumTipSource { ProfileTutorial, MyAccount } diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 73ab05291..0b4ccc7e7 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -993,6 +993,17 @@ Increase the amount to send via Tip Card + + Set Minimum Tip + Minimum Tip + %1$s minimum + %1$s Minimum Tip + Please enter a higher amount + Something Went Wrong + We were unable to save your minimum tip. Please try again + Block Blocked Block diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt index 5471b7054..598b803ed 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt @@ -288,6 +288,9 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { onSetProfilePicture = { viewModel.dispatchEvent(Event.SetProfilePicture) }, + onSetMinimumTip = { + viewModel.dispatchEvent(Event.SetMinimumTip) + }, ) }, footer = { @@ -417,6 +420,7 @@ private fun YouHeader( onClaimUsername: () -> Unit, profileTutorial: List?, onSetProfilePicture: () -> Unit, + onSetMinimumTip: () -> Unit, ) { when (tipCardState) { TipCardState.Unknown -> Unit @@ -445,6 +449,7 @@ private fun YouHeader( onClaimUsername = onClaimUsername, profileTutorial = profileTutorial, onSetProfilePicture = onSetProfilePicture, + onSetMinimumTip = onSetMinimumTip, ) } } @@ -480,6 +485,7 @@ private fun ClaimedTipCard( onClaimUsername: () -> Unit, profileTutorial: List?, onSetProfilePicture: () -> Unit, + onSetMinimumTip: () -> Unit, ) { Column( modifier = Modifier.fillMaxWidth(), @@ -565,8 +571,7 @@ private fun ClaimedTipCard( ) { item -> when (item) { is TutorialItem.ProfilePicture -> onSetProfilePicture() - // Inert: nothing backs a user-set minimum tip yet. - is TutorialItem.MinimumTip -> Unit + is TutorialItem.MinimumTip -> onSetMinimumTip() } } diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt index 979ed2301..7c3530d26 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt @@ -8,6 +8,7 @@ import com.flipcash.app.bills.share.TipCodePreviewCache import com.flipcash.app.core.AppRoute import com.flipcash.app.core.android.VersionInfo import com.flipcash.app.core.DisplayNameSource +import com.flipcash.app.core.MinimumTipSource import com.flipcash.app.core.bill.Scannable import com.flipcash.app.core.extensions.setText import com.flipcash.app.core.share.TipCodeExportFormat @@ -168,6 +169,9 @@ internal class MenuScreenViewModel @Inject constructor( /** The checklist's photo row — opens the photo step of the profile flow on its own. */ data object SetProfilePicture : Event + /** The checklist's minimum-tip row — opens the amount entry for the DM-init fee. */ + data object SetMinimumTip : Event + /** The progress card's tap — claim a handle, or explain why it can't be claimed yet. */ data object ClaimUsername : Event /** The claim prompt's CTA — collect a display name so the account gets a real card. */ @@ -403,6 +407,17 @@ internal class MenuScreenViewModel @Inject constructor( } .launchIn(viewModelScope) + eventFlow + .filterIsInstance() + .onEach { + dispatchEvent( + Event.OpenScreen( + AppRoute.SetMinimumTip(MinimumTipSource.ProfileTutorial) + ) + ) + } + .launchIn(viewModelScope) + eventFlow .filterIsInstance() .mapNotNull { stateFlow.value.tipLink } @@ -567,6 +582,7 @@ internal class MenuScreenViewModel @Inject constructor( Event.ClaimTipCard, Event.ClaimUsername, Event.SetProfilePicture, + Event.SetMinimumTip, Event.ShareTipCard, Event.CopyTipLink, Event.DownloadTipCard, diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileTutorial.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileTutorial.kt index ae5925adf..3e9db39cc 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileTutorial.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileTutorial.kt @@ -9,12 +9,13 @@ import com.flipcash.services.models.UserProfile * Null while the profile is unresolved, so the card is never drawn against a guess — an account * that already has a photo would otherwise flash an outstanding step on the way in. * - * The minimum-tip step is always outstanding; see [TutorialItem.MinimumTip]. + * Both steps read straight off the profile, so a step completed elsewhere — My Account's own + * Minimum Tip row, say — closes here too. */ internal fun profileTutorialItems(profile: UserProfile?): List? { profile ?: return null return listOf( TutorialItem.ProfilePicture(isCompleted = profile.profilePicture != null), - TutorialItem.MinimumTip(), + TutorialItem.MinimumTip(isCompleted = profile.minDmChatInitFee != null), ) } diff --git a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/ProfileTutorialTest.kt b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/ProfileTutorialTest.kt index f0000ca28..6bc0534b9 100644 --- a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/ProfileTutorialTest.kt +++ b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/ProfileTutorialTest.kt @@ -3,6 +3,7 @@ package com.flipcash.app.menu.internal import com.flipcash.app.core.ui.onboarding.TutorialItem import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.MediaItem +import com.getcode.opencode.model.financial.Fiat import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull @@ -14,12 +15,13 @@ class ProfileTutorialTest { // "a picture is set" without needing a mocking library in this module. private val anyPicture = MediaItem(renditions = emptyList()) - private fun profile(picture: MediaItem?) = UserProfile( + private fun profile(picture: MediaItem? = null, minimumTip: Fiat? = null) = UserProfile( displayName = "Brandon", socialAccounts = emptyList(), phoneNumber = null, email = null, profilePicture = picture, + minDmChatInitFee = minimumTip, ) @Test @@ -28,8 +30,8 @@ class ProfileTutorialTest { } @Test - fun `a profile without a picture leaves both steps outstanding`() { - val items = profileTutorialItems(profile(picture = null)) + fun `a bare profile leaves both steps outstanding`() { + val items = profileTutorialItems(profile()) assertEquals(2, items?.size) assertTrue(items!!.none { it.isCompleted }) } @@ -42,8 +44,15 @@ class ProfileTutorialTest { } @Test - fun `the minimum tip step never completes`() { - val items = profileTutorialItems(profile(picture = anyPicture)) - assertTrue(items!!.none { it is TutorialItem.MinimumTip && it.isCompleted }) + fun `a saved minimum tip completes only the minimum tip step`() { + val items = profileTutorialItems(profile(minimumTip = Fiat(1.0))) + assertEquals(1, items?.count { it.isCompleted }) + assertTrue(items!!.first { it is TutorialItem.MinimumTip }.isCompleted) + } + + @Test + fun `a picture and a minimum tip close the checklist`() { + val items = profileTutorialItems(profile(picture = anyPicture, minimumTip = Fiat(1.0))) + assertTrue(items!!.all { it.isCompleted }) } } diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/MyAccountScreen.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/MyAccountScreen.kt index 1c9f63c37..106520716 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/MyAccountScreen.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/MyAccountScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import com.flipcash.app.core.AppRoute import com.flipcash.app.core.DisplayNameSource +import com.flipcash.app.core.MinimumTipSource import com.flipcash.app.myaccount.internal.myaccount.MyAccountScreen import com.flipcash.app.myaccount.internal.myaccount.MyAccountScreenViewModel import com.flipcash.core.R @@ -89,6 +90,13 @@ fun MyAccountScreen() { }.launchIn(this) } + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { navigator.push(AppRoute.SetMinimumTip(MinimumTipSource.MyAccount)) } + .launchIn(this) + } + LaunchedEffect(viewModel) { viewModel.eventFlow .filterIsInstance() diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountMenuItems.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountMenuItems.kt index ee26df945..b7d4dbbd6 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountMenuItems.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountMenuItems.kt @@ -66,6 +66,20 @@ internal data object ProfilePicture : FullMenuItem() { + override val icon: Painter + @Composable get() = painterResource(CoreR.drawable.ic_coins) + override val name: String + @Composable get() = stringResource(CoreR.string.title_minimumTip) + override val action: MyAccountScreenViewModel.Event = + MyAccountScreenViewModel.Event.OnMinimumTipClicked +} + /** * A toggle, not a destination — the screen renders a switch in its trailing slot and routes the tap * through a biometric prompt. Its [action] is what a row tap dispatches, same as the switch. diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountScreenViewModel.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountScreenViewModel.kt index b614fc2f7..31fdf5ddc 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountScreenViewModel.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountScreenViewModel.kt @@ -27,6 +27,7 @@ private val FullMenuList = buildList { add(ChangeDisplayName) add(ChangeUsername) add(ProfilePicture) + add(MinimumTip) add(RequireBiometrics) add(Blocklist) } @@ -80,6 +81,8 @@ internal class MyAccountScreenViewModel @Inject constructor( data object OnEditUsername : Event data object OnProfilePictureClicked : Event data object OnEditProfilePicture : Event + data object OnMinimumTipClicked : Event + data object OnEditMinimumTip : Event data object OnBlocklistClicked: Event data object OnViewBlocklist: Event data object OnContactMethodsClicked : Event @@ -142,6 +145,12 @@ internal class MyAccountScreenViewModel @Inject constructor( dispatchEvent(Event.OnEditProfilePicture) }.launchIn(viewModelScope) + eventFlow + .filterIsInstance() + .onEach { + dispatchEvent(Event.OnEditMinimumTip) + }.launchIn(viewModelScope) + eventFlow .filterIsInstance() .onEach { @@ -178,6 +187,8 @@ internal class MyAccountScreenViewModel @Inject constructor( Event.OnEditUsername, Event.OnProfilePictureClicked, Event.OnEditProfilePicture, + Event.OnMinimumTipClicked, + Event.OnEditMinimumTip, Event.OnContactMethodsClicked, Event.OnViewUserProfile, Event.OnBlocklistClicked, diff --git a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/MyAccountScreenViewModelStateTest.kt b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/MyAccountScreenViewModelStateTest.kt index f815300f4..ee5fcd2bf 100644 --- a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/MyAccountScreenViewModelStateTest.kt +++ b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/MyAccountScreenViewModelStateTest.kt @@ -3,6 +3,7 @@ package com.flipcash.app.myaccount.internal import com.flipcash.app.myaccount.internal.myaccount.Blocklist import com.flipcash.app.myaccount.internal.myaccount.ChangeDisplayName import com.flipcash.app.myaccount.internal.myaccount.ChangeUsername +import com.flipcash.app.myaccount.internal.myaccount.MinimumTip import com.flipcash.app.myaccount.internal.myaccount.MyAccountScreenViewModel import com.flipcash.app.myaccount.internal.myaccount.ProfilePicture import com.flipcash.app.myaccount.internal.myaccount.RequireBiometrics @@ -20,10 +21,10 @@ class MyAccountScreenViewModelStateTest { reduce(MyAccountScreenViewModel.Event.OnUsernameClaimChanged(claimed = true))(state) @Test - fun `default state lists the display name, profile picture, biometrics and blocklist`() { + fun `default state lists the display name, profile picture, minimum tip, biometrics and blocklist`() { val state = MyAccountScreenViewModel.State() assertEquals( - listOf(ChangeDisplayName, ProfilePicture, RequireBiometrics, Blocklist), + listOf(ChangeDisplayName, ProfilePicture, MinimumTip, RequireBiometrics, Blocklist), state.items, ) assertFalse(state.biometricsRequired) @@ -53,7 +54,14 @@ class MyAccountScreenViewModelStateTest { assertTrue(withHandle.usernameClaimed) assertEquals( - listOf(ChangeDisplayName, ChangeUsername, ProfilePicture, RequireBiometrics, Blocklist), + listOf( + ChangeDisplayName, + ChangeUsername, + ProfilePicture, + MinimumTip, + RequireBiometrics, + Blocklist, + ), withHandle.items, ) } @@ -165,6 +173,7 @@ class MyAccountScreenViewModelStateTest { ChangeDisplayName, ChangeUsername, ProfilePicture, + MinimumTip, RequireBiometrics, Blocklist, ), @@ -271,6 +280,8 @@ class MyAccountScreenViewModelStateTest { MyAccountScreenViewModel.Event.OnEditUsername, MyAccountScreenViewModel.Event.OnProfilePictureClicked, MyAccountScreenViewModel.Event.OnEditProfilePicture, + MyAccountScreenViewModel.Event.OnMinimumTipClicked, + MyAccountScreenViewModel.Event.OnEditMinimumTip, MyAccountScreenViewModel.Event.OnContactMethodsClicked, MyAccountScreenViewModel.Event.OnViewUserProfile, MyAccountScreenViewModel.Event.OnBlocklistClicked, diff --git a/apps/flipcash/features/tipping/build.gradle.kts b/apps/flipcash/features/tipping/build.gradle.kts index a44d47325..7a6116d0f 100644 --- a/apps/flipcash/features/tipping/build.gradle.kts +++ b/apps/flipcash/features/tipping/build.gradle.kts @@ -17,6 +17,7 @@ dependencies { implementation(project(":apps:flipcash:shared:amount-entry")) implementation(project(":apps:flipcash:shared:bills")) implementation(project(":apps:flipcash:shared:featureflags")) + implementation(project(":apps:flipcash:shared:payments")) implementation(project(":apps:flipcash:shared:chat")) implementation(project(":apps:flipcash:shared:chat-ui")) implementation(project(":apps:flipcash:shared:shareable")) diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/SetMinimumTipScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/SetMinimumTipScreen.kt new file mode 100644 index 000000000..f84c0794a --- /dev/null +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/SetMinimumTipScreen.kt @@ -0,0 +1,48 @@ +package com.flipcash.app.tipping + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import com.flipcash.app.core.MinimumTipSource +import com.flipcash.app.tipping.internal.SetMinimumTipViewModel +import com.flipcash.features.tipping.R +import com.flipcash.shared.amountentry.AmountEntryScreen +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.ui.components.AppBarWithTitle +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +/** + * Minimum-tip entry (nodes 9541:10951, 9553:113170). Leaving without saving discards the entry — + * there is no draft to keep, so a changed-but-abandoned amount just doesn't reach the profile. + */ +@Composable +fun SetMinimumTipScreen(source: MinimumTipSource) { + val navigator = LocalCodeNavigator.current + val viewModel = hiltViewModel() + + LaunchedEffect(viewModel, source) { viewModel.onSourceResolved(source) } + + AmountEntryScreen( + controller = viewModel.amountDelegate, + onConfirm = { viewModel.dispatchEvent(SetMinimumTipViewModel.Event.ConfirmRequested) }, + largeHeader = true, + appBar = { + AppBarWithTitle( + title = stringResource(R.string.title_minimumTipEntry), + titleAlignment = Alignment.CenterHorizontally, + onBackIconClicked = { navigator.pop() }, + ) + }, + ) + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { navigator.pop() } + .launchIn(this) + } +} diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/SetMinimumTipViewModel.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/SetMinimumTipViewModel.kt new file mode 100644 index 000000000..3f8761d7a --- /dev/null +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/SetMinimumTipViewModel.kt @@ -0,0 +1,220 @@ +package com.flipcash.app.tipping.internal + +import androidx.lifecycle.viewModelScope +import com.flipcash.app.core.MinimumTipSource +import com.flipcash.app.core.ui.ConfirmationStyle +import com.flipcash.features.tipping.R +import com.flipcash.services.controllers.ProfileController +import com.flipcash.services.user.UserManager +import com.flipcash.shared.amountentry.AmountEntryDelegate +import com.flipcash.shared.amountentry.AmountEntryLabel +import com.flipcash.shared.amountentry.AmountEntryStyle +import com.flipcash.shared.payments.TipPaymentDelegate +import com.getcode.manager.BottomBarManager +import com.getcode.opencode.exchange.Exchange +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import com.getcode.util.resources.ResourceHelper +import com.getcode.view.BaseViewModel +import com.getcode.view.LoadingSuccessState +import com.getcode.view.SuccessHoldDuration +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject +import kotlin.math.abs + +/** + * Backs the minimum-tip entry screen (nodes 9541:10951, 9553:113170) — the fee another user has to + * pay to open a DM, which the profile carries as `minDmChatInitFee`. + * + * Two things separate it from the send-side [TipAmountEntryViewModel]: there is no ceiling, since a + * user can ask for any amount regardless of what anyone can afford, so the preset minimum is the + * only bound and it shows as a standing hint rather than only on error; and the confirm action is a + * save, so it stays inert until the entry actually differs from what is already stored. + */ +@HiltViewModel +internal class SetMinimumTipViewModel @Inject constructor( + exchange: Exchange, + private val resources: ResourceHelper, + private val profileController: ProfileController, + userManager: UserManager, + tipPaymentDelegate: TipPaymentDelegate, +) : BaseViewModel( + initialState = State(), + updateStateForEvent = updateStateForEvent, +) { + + data class State( + /** Currency the keypad is entering in — kept in sync with the preferred rate. */ + val currency: CurrencyCode = CurrencyCode.USD, + /** The fee already on the profile, or null when none has been set. */ + val saved: Fiat? = null, + val saving: LoadingSuccessState = LoadingSuccessState(), + ) + + sealed interface Event { + /** Preferred currency resolved/changed; keeps the entry currency in sync. */ + data class CurrencyChanged(val currency: CurrencyCode) : Event + + /** The stored fee resolved or changed under us. */ + data class SavedFeeChanged(val fee: Fiat?) : Event + + /** User asked to save the currently entered amount. */ + data object ConfirmRequested : Event + + data class UpdateSavingState( + val loading: Boolean = false, + val success: Boolean = false, + val error: Boolean = false, + ) : Event + + /** The fee was stored — the screen should dismiss. */ + data object Saved : Event + } + + private val minimumAmount = tipPaymentDelegate.minTipAmount + + // Gates the confirm action on the entry differing from what is stored. Fed from init, because + // it needs the delegate's own state and so cannot be built before the delegate exists. + private val entryChanged = MutableStateFlow(false) + + // The label is the only thing the entry point changes, and the screen supplies it, so the + // style is a flow rather than a constant. + private val style = MutableStateFlow(styleFor(MinimumTipSource.MyAccount)) + + val amountDelegate = AmountEntryDelegate( + exchange = exchange, + scope = viewModelScope, + style = style, + loadingState = stateFlow.map { it.saving } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), LoadingSuccessState()), + // No ceiling: what someone asks to be paid isn't bounded by any balance. The preset + // minimum is the only bound, and with no max it reads as a standing hint. + minimumAmount = minimumAmount, + confirmEnabled = entryChanged, + ) + + // Prefill happens once. `prefill` types on top of the entry rather than replacing it, so a + // second pass would corrupt whatever the user typed in the meantime. + private var prefilled = false + + init { + exchange.observePreferredRate() + .onEach { rate -> + exchange.getCurrency(rate.currency.name)?.let { amountDelegate.onCurrencyChanged(it) } + dispatchEvent(Event.CurrencyChanged(rate.currency)) + } + .launchIn(viewModelScope) + + // Registered after the rate above so the keypad's fraction units are set before the + // prefill scales the stored amount. + userManager.state + .map { it.userProfile?.minDmChatInitFee } + .distinctUntilChanged() + .onEach { saved -> + dispatchEvent(Event.SavedFeeChanged(saved)) + if (!prefilled && saved != null) { + prefilled = true + amountDelegate.prefill(saved.decimalValue) + } + } + .launchIn(viewModelScope) + + combine( + amountDelegate.state.map { it.enteredAmount }.distinctUntilChanged(), + stateFlow.map { it.saved }.distinctUntilChanged(), + ) { entered, saved -> + // Both sides are money in the same currency; the tolerance is only there to keep + // binary-fraction noise from reading as an edit. + abs(entered - (saved?.decimalValue ?: 0.0)) > 0.0001 + } + .onEach { entryChanged.value = it } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { + if (!stateFlow.value.saving.isIdle) return@onEach + + val entered = amountDelegate.state.value.enteredAmount + if (entered <= 0.0) return@onEach + val amount = Fiat(entered, stateFlow.value.currency) + + val min = minimumAmount.value + if (min != null && amount.valueLessThan(min)) { + BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_minimumTip, min.formatted()), + message = resources.getString(R.string.error_description_minimumTip), + ) + return@onEach + } + + dispatchEvent(Event.UpdateSavingState(loading = true)) + profileController.setMinDmChatInitFee(amount) + .onSuccess { + viewModelScope.launch { + dispatchEvent(Event.UpdateSavingState(success = true)) + delay(SuccessHoldDuration) + dispatchEvent(Event.Saved) + dispatchEvent(Event.UpdateSavingState()) + } + } + .onFailure { + dispatchEvent(Event.UpdateSavingState()) + BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_minimumTipFailed), + message = resources.getString(R.string.error_description_minimumTipFailed), + ) + } + } + .launchIn(viewModelScope) + } + + /** Called by the screen once the route's entry point is known. */ + fun onSourceResolved(source: MinimumTipSource) { + style.value = styleFor(source) + } + + private fun styleFor(source: MinimumTipSource) = AmountEntryStyle( + actionLabel = AmountEntryLabel.Plain( + resources.getString( + when (source) { + MinimumTipSource.ProfileTutorial -> R.string.action_next + MinimumTipSource.MyAccount -> R.string.action_save + } + ) + ), + actionStyle = ConfirmationStyle.Button, + belowMinHint = { resources.getString(R.string.subtitle_minimumTipHint, it) }, + ) + + companion object { + val updateStateForEvent: (Event) -> (State.() -> State) = { event -> + when (event) { + is Event.CurrencyChanged -> { state -> state.copy(currency = event.currency) } + is Event.SavedFeeChanged -> { state -> state.copy(saved = event.fee) } + is Event.UpdateSavingState -> { state -> + state.copy( + saving = LoadingSuccessState( + loading = event.loading, + success = event.success, + error = event.error, + ) + ) + } + is Event.ConfirmRequested -> { state -> state } + is Event.Saved -> { state -> state } + } + } + } +} diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/SetMinimumTipStateTest.kt b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/SetMinimumTipStateTest.kt new file mode 100644 index 000000000..0a5a5f3ce --- /dev/null +++ b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/SetMinimumTipStateTest.kt @@ -0,0 +1,72 @@ +package com.flipcash.app.tipping.internal + +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SetMinimumTipStateTest { + + private val reduce = SetMinimumTipViewModel.updateStateForEvent + + private fun apply( + event: SetMinimumTipViewModel.Event, + state: SetMinimumTipViewModel.State = SetMinimumTipViewModel.State(), + ) = reduce(event)(state) + + @Test + fun `nothing is saved by default`() { + val state = SetMinimumTipViewModel.State() + assertEquals(null, state.saved) + assertTrue(state.saving.isIdle) + } + + @Test + fun `the resolved fee lands in state`() { + val fee = Fiat(5.0, CurrencyCode.USD) + assertEquals(fee, apply(SetMinimumTipViewModel.Event.SavedFeeChanged(fee)).saved) + } + + @Test + fun `clearing the fee empties it again`() { + val withFee = apply( + SetMinimumTipViewModel.Event.SavedFeeChanged(Fiat(5.0, CurrencyCode.USD)) + ) + assertEquals(null, apply(SetMinimumTipViewModel.Event.SavedFeeChanged(null), withFee).saved) + } + + @Test + fun `the preferred rate sets the entry currency`() { + val updated = apply(SetMinimumTipViewModel.Event.CurrencyChanged(CurrencyCode.EUR)) + assertEquals(CurrencyCode.EUR, updated.currency) + } + + @Test + fun `saving runs loading then success then back to idle`() { + val loading = apply(SetMinimumTipViewModel.Event.UpdateSavingState(loading = true)) + assertTrue(loading.saving.loading) + assertFalse(loading.saving.isIdle) + + val success = apply( + SetMinimumTipViewModel.Event.UpdateSavingState(success = true), + loading, + ) + assertTrue(success.saving.success) + assertFalse(success.saving.loading) + + assertTrue(apply(SetMinimumTipViewModel.Event.UpdateSavingState(), success).saving.isIdle) + } + + @Test + fun `the navigation events leave state alone`() { + val state = SetMinimumTipViewModel.State(saved = Fiat(5.0, CurrencyCode.USD)) + listOf( + SetMinimumTipViewModel.Event.ConfirmRequested, + SetMinimumTipViewModel.Event.Saved, + ).forEach { event -> + assertEquals(state, apply(event, state), "Event $event should be no-op") + } + } +} diff --git a/apps/flipcash/shared/amount-entry/src/main/kotlin/com/flipcash/shared/amountentry/AmountEntryDelegate.kt b/apps/flipcash/shared/amount-entry/src/main/kotlin/com/flipcash/shared/amountentry/AmountEntryDelegate.kt index 4e47c3bc2..1a1f7c728 100644 --- a/apps/flipcash/shared/amount-entry/src/main/kotlin/com/flipcash/shared/amountentry/AmountEntryDelegate.kt +++ b/apps/flipcash/shared/amount-entry/src/main/kotlin/com/flipcash/shared/amountentry/AmountEntryDelegate.kt @@ -34,6 +34,10 @@ class AmountEntryDelegate( loadingState: StateFlow = MutableStateFlow(LoadingSuccessState()), maxAmount: StateFlow = MutableStateFlow(null), minimumAmount: StateFlow = MutableStateFlow(null), + // An extra gate on the confirm action, ANDed with "something was entered". For flows where a + // valid amount still isn't actionable — a settings screen whose Save stays inert until the + // entry differs from the saved value. Defaults to always-allowed. + confirmEnabled: StateFlow = MutableStateFlow(true), // Emits whenever the selected token changes. Like a region/currency change, switching // tokens re-denominates the entry, so the typed amount is reset (see init). Defaults to a // no-op for flows without a token concept. @@ -47,8 +51,19 @@ class AmountEntryDelegate( loadingState: StateFlow = MutableStateFlow(LoadingSuccessState()), maxAmount: StateFlow = MutableStateFlow(null), minimumAmount: StateFlow = MutableStateFlow(null), + confirmEnabled: StateFlow = MutableStateFlow(true), tokenChanges: Flow<*> = emptyFlow(), - ) : this(exchange, scope, maxLength, MutableStateFlow(style), loadingState, maxAmount, minimumAmount, tokenChanges) + ) : this( + exchange, + scope, + maxLength, + MutableStateFlow(style), + loadingState, + maxAmount, + minimumAmount, + confirmEnabled, + tokenChanges, + ) data class State( val currency: CurrencyHolder = CurrencyHolder(), @@ -65,9 +80,16 @@ class AmountEntryDelegate( private val _state = MutableStateFlow(State()) override val state: StateFlow = _state.asStateFlow() + // The bounds and the confirm gate travel together because `combine` tops out at five typed + // sources and the config already needs state, style and loading. + private val bounds: Flow> = + combine(maxAmount, minimumAmount, confirmEnabled) { max, min, allowed -> + Triple(max, min, allowed) + } + override val config: StateFlow = combine( - _state, style, loadingState, maxAmount, minimumAmount, - ) { delegateState, currentStyle, loading, max, min -> + _state, style, loadingState, bounds, + ) { delegateState, currentStyle, loading, (max, min, confirmAllowed) -> val isBelowMin = min != null && currentStyle.belowMinHint != null && !delegateState.isEmpty && delegateState.enteredAmount > 0 && Fiat(delegateState.enteredAmount, min.currencyCode).valueLessThan(min) @@ -79,12 +101,16 @@ class AmountEntryDelegate( isBelowMin -> AmountEntryHint.Error(currentStyle.belowMinHint!!(min!!.formatted())) isOverMax -> AmountEntryHint.Error(currentStyle.overMaxHint(max!!.formatted())) max != null -> AmountEntryHint.Info(currentStyle.infoHint(max.formatted())) + // No ceiling to describe, so the floor is the standing hint rather than only an + // error — it tells the user the rule before they break it. + min != null && currentStyle.belowMinHint != null -> + AmountEntryHint.Info(currentStyle.belowMinHint!!(min.formatted())) else -> AmountEntryHint.None } AmountEntryConfig( hint = hint, - canConfirm = delegateState.enteredAmount > 0.0, + canConfirm = delegateState.enteredAmount > 0.0 && confirmAllowed, canChangeCurrency = currentStyle.canChangeCurrency, action = AmountEntryAction( label = currentStyle.actionLabel, diff --git a/apps/flipcash/shared/amount-entry/src/test/kotlin/com/flipcash/shared/amountentry/AmountEntryDelegateTest.kt b/apps/flipcash/shared/amount-entry/src/test/kotlin/com/flipcash/shared/amountentry/AmountEntryDelegateTest.kt index edb48e489..dde924aae 100644 --- a/apps/flipcash/shared/amount-entry/src/test/kotlin/com/flipcash/shared/amountentry/AmountEntryDelegateTest.kt +++ b/apps/flipcash/shared/amount-entry/src/test/kotlin/com/flipcash/shared/amountentry/AmountEntryDelegateTest.kt @@ -47,6 +47,7 @@ class AmountEntryDelegateTest { loadingState: MutableStateFlow = MutableStateFlow(LoadingSuccessState()), maxAmount: MutableStateFlow = MutableStateFlow(null), minimumAmount: MutableStateFlow = MutableStateFlow(null), + confirmEnabled: MutableStateFlow = MutableStateFlow(true), ): AmountEntryDelegate { val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) val delegate = AmountEntryDelegate( @@ -56,6 +57,7 @@ class AmountEntryDelegateTest { loadingState = loadingState, maxAmount = maxAmount, minimumAmount = minimumAmount, + confirmEnabled = confirmEnabled, ) // Keep a subscriber active so WhileSubscribed produces values delegate.config.launchIn(scope) @@ -392,6 +394,52 @@ class AmountEntryDelegateTest { assertTrue(hint.text.startsWith("Min is")) } + @Test + fun `a floor with no ceiling is a standing info hint`() = runTest { + val min = MutableStateFlow(Fiat(5.0, CurrencyCode.USD)) + val delegate = createDelegate( + style = AmountEntryStyle( + actionLabel = AmountEntryLabel.Plain("Save"), + belowMinHint = { "Min is $it" }, + ), + minimumAmount = min, + ) + delegate.onCurrencyChanged(usd) + + // Nothing entered, no maximum to describe: the floor states the rule up front. + val resting = delegate.config.value.hint + assertIs(resting) + assertTrue(resting.text.startsWith("Min is")) + + delegate.onNumber(2) + assertIs(delegate.config.value.hint) + } + + // --------------------------------------------------------------- + // Config derivation — confirmEnabled + // --------------------------------------------------------------- + + @Test + fun `confirm stays disabled while the extra gate is closed`() = runTest { + val gate = MutableStateFlow(false) + val delegate = createDelegate(confirmEnabled = gate) + delegate.onCurrencyChanged(usd) + delegate.onNumber(5) + + assertFalse(delegate.config.value.canConfirm) + + gate.value = true + assertTrue(delegate.config.value.canConfirm) + } + + @Test + fun `an open gate still needs an entered amount`() = runTest { + val delegate = createDelegate(confirmEnabled = MutableStateFlow(true)) + delegate.onCurrencyChanged(usd) + + assertFalse(delegate.config.value.canConfirm) + } + // --------------------------------------------------------------- // Config derivation — loadingState // --------------------------------------------------------------- diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ProfileController.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ProfileController.kt index 5cc47231c..0a2b789aa 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ProfileController.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ProfileController.kt @@ -134,6 +134,7 @@ class ProfileController @Inject constructor( ?: return Result.failure(Throwable("No account cluster in UserManager")) return repository.setMinDmChatInitFee(owner, fee) + .onSuccess { mergeLocalProfile { it.copy(minDmChatInitFee = fee) } } } /** From 80e8858e44ca461bf63176861b97f1af40ff7423 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 28 Aug 2026 11:44:06 -0400 Subject: [PATCH 2/2] refactor(userprofile): ask the profile flow for a list of steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `UpdateUserProfile` took a `nameSource` plus `includeName`/`includePhoto`/ `includeUsername`, so every caller had to name a display-name source even when it skipped the name step — the username-only call at MenuScreenViewModel carried a comment saying exactly that. Adding minimum tip would have made it four booleans. The route now takes `steps: List` in render order, and each step carries its own parameters: `Name(source)` needs a `DisplayNameSource`, the others need nothing. A caller asks for the steps it wants and names nothing else. Minimum tip becomes a step rather than its own route, which is where it belongs — it edits the profile like the other three. That deletes `MinimumTipSource`: "Next" vs "Save" now follows whether another step comes after this one, the same rule the existing steps already follow by hand. The "Finish Your Profile" checklist disappears once both its steps are done, instead of standing there at 2/2 with two ticks. --- .../ui/navigation/AppScreenContent.kt | 2 - .../app/internal/ui/navigation/MainRoot.kt | 5 +-- .../navigation/BuildNavGraphForLaunchTest.kt | 8 +++- .../kotlin/com/flipcash/app/core/AppRoute.kt | 39 +++---------------- .../app/core/userprofile/UpdateProfileStep.kt | 23 ++++++++--- .../app/login/OnboardingFlowScreen.kt | 5 +-- .../app/menu/internal/MenuScreenViewModel.kt | 28 ++++++------- .../app/menu/internal/ProfileTutorial.kt | 6 ++- .../app/menu/internal/ProfileTutorialTest.kt | 5 +-- .../flipcash/app/myaccount/MyAccountScreen.kt | 24 ++++++------ .../features/tipping/build.gradle.kts | 1 - .../tipping/internal/screens/TipInfoScreen.kt | 10 +++-- .../features/user-profile/build.gradle.kts | 2 + .../userprofile/UserProfileSetupFlowScreen.kt | 8 +++- .../internal/mintip/MinimumTipEntryScreen.kt} | 31 ++++++++------- .../mintip/MinimumTipEntryViewModel.kt} | 36 +++++++---------- .../mintip/MinimumTipEntryStateTest.kt} | 32 +++++++-------- 17 files changed, 126 insertions(+), 139 deletions(-) rename apps/flipcash/features/{tipping/src/main/kotlin/com/flipcash/app/tipping/SetMinimumTipScreen.kt => user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/mintip/MinimumTipEntryScreen.kt} (50%) rename apps/flipcash/features/{tipping/src/main/kotlin/com/flipcash/app/tipping/internal/SetMinimumTipViewModel.kt => user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/mintip/MinimumTipEntryViewModel.kt} (86%) rename apps/flipcash/features/{tipping/src/test/kotlin/com/flipcash/app/tipping/internal/SetMinimumTipStateTest.kt => user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/mintip/MinimumTipEntryStateTest.kt} (52%) diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt index 27dff4248..2de025885 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt @@ -26,7 +26,6 @@ import com.flipcash.app.cash.CashScreen import com.flipcash.app.contact.verification.VerificationFlowScreen import com.flipcash.app.currencycreator.CurrencyCreatorFlowScreen import com.flipcash.app.tipping.NewChatScreen -import com.flipcash.app.tipping.SetMinimumTipScreen import com.flipcash.app.tipping.TipAmountEntryScreen import com.flipcash.app.tipping.TippingFlowScreen import com.flipcash.shared.transactionhistory.ActivityHistoryScreen @@ -135,7 +134,6 @@ fun appEntryProvider( } // User Profile Management - annotatedEntry { key -> SetMinimumTipScreen(key.source) } annotatedEntry { key -> UpdateUserProfileFlowScreen(route = key, resultStateRegistry = resultStateRegistry) diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/MainRoot.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/MainRoot.kt index 9da1857e3..72712c060 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/MainRoot.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/MainRoot.kt @@ -25,6 +25,7 @@ import com.flipcash.app.android.R import com.flipcash.app.core.LocalUserManager import com.flipcash.app.core.AppRoute import com.flipcash.app.core.DisplayNameSource +import com.flipcash.app.core.userprofile.UpdateProfileStep import com.flipcash.app.core.navigation.DeeplinkAction import com.flipcash.app.core.navigation.homeRoute import com.flipcash.app.core.extensions.navigateAll @@ -194,9 +195,7 @@ internal fun buildNavGraphForLaunch( listOf( AppRoute.UpdateUserProfile( origin = AppRoute.OnboardingFlow(), - nameSource = DisplayNameSource.Onboarding, - includeName = true, - includePhoto = false, + steps = listOf(UpdateProfileStep.Name(DisplayNameSource.Onboarding)), target = AppRoute.OnboardingFlow( phase = AppRoute.OnboardingFlow.Phase.Permissions, skipContacts = true, diff --git a/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/navigation/BuildNavGraphForLaunchTest.kt b/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/navigation/BuildNavGraphForLaunchTest.kt index 9a691e9d6..b8c55865f 100644 --- a/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/navigation/BuildNavGraphForLaunchTest.kt +++ b/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/navigation/BuildNavGraphForLaunchTest.kt @@ -1,6 +1,8 @@ package com.flipcash.app.internal.ui.navigation import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.DisplayNameSource +import com.flipcash.app.core.userprofile.UpdateProfileStep import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.services.models.chat.ChatId import com.getcode.solana.keys.Mint @@ -167,8 +169,10 @@ class BuildNavGraphForLaunchTest { fun `onboarding at DisplayName resume point routes to display name entry then permissions`() { val result = build(AuthState.Onboarding(AuthState.ResumePoint.DisplayName))!! val route = assertIs(result.baseRoutes.single()) - assertTrue(route.includeName) - assertEquals(false, route.includePhoto) + assertEquals( + listOf(UpdateProfileStep.Name(DisplayNameSource.Onboarding)), + route.steps, + ) val target = assertIs(route.target) assertEquals(AppRoute.OnboardingFlow.Phase.Permissions, target.phase) } diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt index d59cbd2c8..8bb6dfe4f 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt @@ -144,32 +144,22 @@ sealed interface AppRoute : NavKey, Parcelable { @Serializable @Parcelize + /** + * The profile editor, as an ordered list of the [steps] the caller wants. Each step carries its + * own parameters, so asking for a subset costs nothing beyond a shorter list. + */ data class UpdateUserProfile( val origin: AppRoute, - val nameSource: DisplayNameSource, - val includeName: Boolean = true, - val includePhoto: Boolean = true, - // Off by default: the username step is gated on a minimum balance and is never part of - // onboarding, so only the surfaces that qualify the account ask for it. - val includeUsername: Boolean = false, + val steps: List, val target: AppRoute? = null, // When false, the first step has no back affordance and system back is swallowed — // used in onboarding where display-name entry is a mandatory, non-dismissable step. val allowBack: Boolean = true, ): AppRoute, FlowRouteWithResult { override val initialStack: List - get() = buildUpdateUserProfileStack(includeName, includeUsername, includePhoto) + get() = steps } - /** - * Minimum-tip entry — the fee another user has to pay to open a DM with you, which the profile - * carries as `minDmChatInitFee`. Reached from the You tab's profile checklist and from My - * Account; [source] only decides how the confirm button reads. - */ - @Serializable - @Parcelize - data class SetMinimumTip(val source: MinimumTipSource) : AppRoute - @Serializable @Parcelize sealed interface Sheets : AppRoute { @@ -380,23 +370,6 @@ private fun buildVerificationInitialStack( // Ordered list of the steps the flow should walk (via FlowNavigator.proceed()) — name, then // username, then photo. In edit mode only the requested step(s) are included. -private fun buildUpdateUserProfileStack( - includeName: Boolean, - includeUsername: Boolean, - includePhoto: Boolean, -): List = buildList { - if (includeName) add(UpdateProfileStep.Name) - if (includeUsername) add(UpdateProfileStep.Username) - if (includePhoto) add(UpdateProfileStep.Photo) -} - /** Where a display-name entry flow was launched from. Reported as the `Source` analytics property. */ @Serializable enum class DisplayNameSource { Onboarding, MyAccount, TipCardSetup } - -/** - * Where a minimum-tip entry was launched from. The checklist row is a setup step, so its button - * reads "Next"; My Account is editing a setting that already exists, so its button reads "Save". - */ -@Serializable -enum class MinimumTipSource { ProfileTutorial, MyAccount } diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/userprofile/UpdateProfileStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/userprofile/UpdateProfileStep.kt index a00dc960b..6ff720c01 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/userprofile/UpdateProfileStep.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/userprofile/UpdateProfileStep.kt @@ -1,20 +1,27 @@ package com.flipcash.app.core.userprofile import android.os.Parcelable +import com.flipcash.app.core.DisplayNameSource import com.getcode.navigation.flow.FlowStep import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable +/** + * One screen of the profile editor. A caller asks for the steps it wants, in order, and each step + * carries whatever it alone needs — so a flow that skips the name step never has to name a + * [DisplayNameSource] for it. + */ @Serializable sealed interface UpdateProfileStep : FlowStep, Parcelable { + /** @param source where the entry was launched from; reported as the `Source` analytics property. */ @Parcelize @Serializable - object Name : UpdateProfileStep + data class Name(val source: DisplayNameSource) : UpdateProfileStep /** - * Claiming the public `@handle`. Optional and off by default: unlike the display name it is - * never part of onboarding — the server gates it behind a minimum balance, so it is reached - * from My Account or the "You" tab once the account qualifies. + * Claiming the public `@handle`. Never part of onboarding — the server gates it behind a + * minimum balance, so it is reached from My Account or the "You" tab once the account + * qualifies. */ @Parcelize @Serializable @@ -24,5 +31,11 @@ sealed interface UpdateProfileStep : FlowStep, Parcelable { @Serializable object Photo : UpdateProfileStep - + /** + * The fee another user has to pay to open a DM, which the profile carries as + * `minDmChatInitFee`. + */ + @Parcelize + @Serializable + object MinimumTip : UpdateProfileStep } diff --git a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt index df97040be..43d95ec2f 100644 --- a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt +++ b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt @@ -26,6 +26,7 @@ import com.flipcash.app.analytics.Action import com.flipcash.app.analytics.Button import com.flipcash.app.core.AppRoute import com.flipcash.app.core.DisplayNameSource +import com.flipcash.app.core.userprofile.UpdateProfileStep import com.flipcash.app.core.LocalUserManager import com.flipcash.app.core.extensions.openAsSheet import com.flipcash.app.core.navigation.homeRoute @@ -267,9 +268,7 @@ private fun FlowNavigator.proceedToNameOrPermi navigate( AppRoute.UpdateUserProfile( origin = AppRoute.OnboardingFlow(), - nameSource = DisplayNameSource.Onboarding, - includeName = true, - includePhoto = false, + steps = listOf(UpdateProfileStep.Name(DisplayNameSource.Onboarding)), target = AppRoute.OnboardingFlow( phase = AppRoute.OnboardingFlow.Phase.Permissions, skipContacts = true, diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt index 7c3530d26..8062d41b1 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt @@ -8,7 +8,7 @@ import com.flipcash.app.bills.share.TipCodePreviewCache import com.flipcash.app.core.AppRoute import com.flipcash.app.core.android.VersionInfo import com.flipcash.app.core.DisplayNameSource -import com.flipcash.app.core.MinimumTipSource +import com.flipcash.app.core.userprofile.UpdateProfileStep import com.flipcash.app.core.bill.Scannable import com.flipcash.app.core.extensions.setText import com.flipcash.app.core.share.TipCodeExportFormat @@ -327,11 +327,7 @@ internal class MenuScreenViewModel @Inject constructor( Event.OpenScreen( AppRoute.UpdateUserProfile( origin = AppRoute.Sheets.Menu, - // Inert: the name step is skipped, but the route asks for a source. - nameSource = DisplayNameSource.TipCardSetup, - includeName = false, - includePhoto = false, - includeUsername = true, + steps = listOf(UpdateProfileStep.Username), ) ) ) @@ -378,10 +374,8 @@ internal class MenuScreenViewModel @Inject constructor( Event.OpenScreen( AppRoute.UpdateUserProfile( origin = AppRoute.Sheets.Menu, - nameSource = DisplayNameSource.TipCardSetup, - includeName = true, - // Explicitly false: a name is all a tip card needs. - includePhoto = false, + // A name is all a tip card needs. + steps = listOf(UpdateProfileStep.Name(DisplayNameSource.TipCardSetup)), ) ) ) @@ -395,12 +389,9 @@ internal class MenuScreenViewModel @Inject constructor( Event.OpenScreen( AppRoute.UpdateUserProfile( origin = AppRoute.Sheets.Menu, - nameSource = DisplayNameSource.MyAccount, - // Photo only: the account already has a name and a card by the time - // this checklist is drawn, so the flow reduces to the one step. - includeName = false, - includePhoto = true, - includeUsername = false, + // The account already has a name and a card by the time this + // checklist is drawn, so the flow reduces to the one step. + steps = listOf(UpdateProfileStep.Photo), ) ) ) @@ -412,7 +403,10 @@ internal class MenuScreenViewModel @Inject constructor( .onEach { dispatchEvent( Event.OpenScreen( - AppRoute.SetMinimumTip(MinimumTipSource.ProfileTutorial) + AppRoute.UpdateUserProfile( + origin = AppRoute.Sheets.Menu, + steps = listOf(UpdateProfileStep.MinimumTip), + ) ) ) } diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileTutorial.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileTutorial.kt index 3e9db39cc..7a4bee1ce 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileTutorial.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileTutorial.kt @@ -7,15 +7,17 @@ import com.flipcash.services.models.UserProfile * The "Finish Your Profile" checklist for the "You" tab (node 9544:18140). * * Null while the profile is unresolved, so the card is never drawn against a guess — an account - * that already has a photo would otherwise flash an outstanding step on the way in. + * that already has a photo would otherwise flash an outstanding step on the way in. Null again once + * every step is done: a checklist with nothing left to do is just a row of ticks. * * Both steps read straight off the profile, so a step completed elsewhere — My Account's own * Minimum Tip row, say — closes here too. */ internal fun profileTutorialItems(profile: UserProfile?): List? { profile ?: return null - return listOf( + val items = listOf( TutorialItem.ProfilePicture(isCompleted = profile.profilePicture != null), TutorialItem.MinimumTip(isCompleted = profile.minDmChatInitFee != null), ) + return items.takeUnless { steps -> steps.all { it.isCompleted } } } diff --git a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/ProfileTutorialTest.kt b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/ProfileTutorialTest.kt index 6bc0534b9..b1bd50cdd 100644 --- a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/ProfileTutorialTest.kt +++ b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/ProfileTutorialTest.kt @@ -51,8 +51,7 @@ class ProfileTutorialTest { } @Test - fun `a picture and a minimum tip close the checklist`() { - val items = profileTutorialItems(profile(picture = anyPicture, minimumTip = Fiat(1.0))) - assertTrue(items!!.all { it.isCompleted }) + fun `a picture and a minimum tip take the checklist away entirely`() { + assertNull(profileTutorialItems(profile(picture = anyPicture, minimumTip = Fiat(1.0)))) } } diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/MyAccountScreen.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/MyAccountScreen.kt index 106520716..97c0d48e9 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/MyAccountScreen.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/MyAccountScreen.kt @@ -11,7 +11,7 @@ import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import com.flipcash.app.core.AppRoute import com.flipcash.app.core.DisplayNameSource -import com.flipcash.app.core.MinimumTipSource +import com.flipcash.app.core.userprofile.UpdateProfileStep import com.flipcash.app.myaccount.internal.myaccount.MyAccountScreen import com.flipcash.app.myaccount.internal.myaccount.MyAccountScreenViewModel import com.flipcash.core.R @@ -51,9 +51,7 @@ fun MyAccountScreen() { navigator.push( AppRoute.UpdateUserProfile( origin = AppRoute.Menu.MyAccount, - nameSource = DisplayNameSource.MyAccount, - includeName = true, - includePhoto = false, + steps = listOf(UpdateProfileStep.Name(DisplayNameSource.MyAccount)), ) ) }.launchIn(this) @@ -66,10 +64,7 @@ fun MyAccountScreen() { navigator.push( AppRoute.UpdateUserProfile( origin = AppRoute.Menu.MyAccount, - nameSource = DisplayNameSource.MyAccount, - includeName = false, - includePhoto = false, - includeUsername = true, + steps = listOf(UpdateProfileStep.Username), ) ) }.launchIn(this) @@ -82,9 +77,7 @@ fun MyAccountScreen() { navigator.push( AppRoute.UpdateUserProfile( origin = AppRoute.Menu.MyAccount, - nameSource = DisplayNameSource.MyAccount, - includeName = false, - includePhoto = true, + steps = listOf(UpdateProfileStep.Photo), ) ) }.launchIn(this) @@ -93,7 +86,14 @@ fun MyAccountScreen() { LaunchedEffect(viewModel) { viewModel.eventFlow .filterIsInstance() - .onEach { navigator.push(AppRoute.SetMinimumTip(MinimumTipSource.MyAccount)) } + .onEach { + navigator.push( + AppRoute.UpdateUserProfile( + origin = AppRoute.Menu.MyAccount, + steps = listOf(UpdateProfileStep.MinimumTip), + ) + ) + } .launchIn(this) } diff --git a/apps/flipcash/features/tipping/build.gradle.kts b/apps/flipcash/features/tipping/build.gradle.kts index 7a6116d0f..a44d47325 100644 --- a/apps/flipcash/features/tipping/build.gradle.kts +++ b/apps/flipcash/features/tipping/build.gradle.kts @@ -17,7 +17,6 @@ dependencies { implementation(project(":apps:flipcash:shared:amount-entry")) implementation(project(":apps:flipcash:shared:bills")) implementation(project(":apps:flipcash:shared:featureflags")) - implementation(project(":apps:flipcash:shared:payments")) implementation(project(":apps:flipcash:shared:chat")) implementation(project(":apps:flipcash:shared:chat-ui")) implementation(project(":apps:flipcash:shared:shareable")) diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/TipInfoScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/TipInfoScreen.kt index 8fc168c3c..2d6ee799e 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/TipInfoScreen.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/TipInfoScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewWrapper import com.flipcash.app.core.AppRoute import com.flipcash.app.core.DisplayNameSource +import com.flipcash.app.core.userprofile.UpdateProfileStep import com.flipcash.app.core.LocalUserManager import com.flipcash.app.core.tipping.TipResult import com.flipcash.app.core.tipping.TipStep @@ -56,9 +57,12 @@ internal fun TipInfoScreen() { flowNavigator.navigate( AppRoute.UpdateUserProfile( origin = AppRoute.Sheets.Tips(), - nameSource = DisplayNameSource.TipCardSetup, - includeName = userManager?.profile?.displayName.isNullOrEmpty(), - includePhoto = false, // explicity false for now + // A card only needs a name, and only if the account hasn't got one. + steps = if (userManager?.profile?.displayName.isNullOrEmpty()) { + listOf(UpdateProfileStep.Name(DisplayNameSource.TipCardSetup)) + } else { + emptyList() + }, target = AppRoute.Sheets.Tips(resumed = true), ) ) diff --git a/apps/flipcash/features/user-profile/build.gradle.kts b/apps/flipcash/features/user-profile/build.gradle.kts index 22a363ef7..e4a83cc55 100644 --- a/apps/flipcash/features/user-profile/build.gradle.kts +++ b/apps/flipcash/features/user-profile/build.gradle.kts @@ -14,10 +14,12 @@ dependencies { implementation(libs.bundles.kotlinx.serialization) + implementation(project(":apps:flipcash:shared:amount-entry")) implementation(project(":apps:flipcash:shared:analytics")) implementation(project(":apps:flipcash:shared:blob")) implementation(project(":apps:flipcash:shared:common-ui")) implementation(project(":apps:flipcash:shared:featureflags")) + implementation(project(":apps:flipcash:shared:payments")) implementation(project(":apps:flipcash:shared:userflags")) implementation(project(":libs:messaging")) diff --git a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/UserProfileSetupFlowScreen.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/UserProfileSetupFlowScreen.kt index 32ead4fa3..e93fe9698 100644 --- a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/UserProfileSetupFlowScreen.kt +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/UserProfileSetupFlowScreen.kt @@ -7,6 +7,7 @@ import androidx.navigation3.runtime.entryProvider import com.flipcash.app.core.AppRoute import com.flipcash.app.core.userprofile.UpdateProfileResult import com.flipcash.app.core.userprofile.UpdateProfileStep +import com.flipcash.app.userprofile.internal.mintip.MinimumTipEntryScreen import com.flipcash.app.userprofile.internal.name.NameEntryScreen import com.flipcash.app.userprofile.internal.photo.PhotoSelectionScreen import com.flipcash.app.userprofile.internal.username.UsernameEntryScreen @@ -58,8 +59,8 @@ fun UpdateUserProfileFlowScreen( private fun profileUpdateProvider( route: AppRoute.UpdateUserProfile, ): (NavKey) -> NavEntry = entryProvider { - annotatedEntry { - NameEntryScreen(source = route.nameSource, allowBack = route.allowBack) + annotatedEntry { key -> + NameEntryScreen(source = key.source, allowBack = route.allowBack) } annotatedEntry { UsernameEntryScreen() @@ -67,4 +68,7 @@ private fun profileUpdateProvider( annotatedEntry { PhotoSelectionScreen() } + annotatedEntry { + MinimumTipEntryScreen(isLastStep = route.steps.lastOrNull() == UpdateProfileStep.MinimumTip) + } } diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/SetMinimumTipScreen.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/mintip/MinimumTipEntryScreen.kt similarity index 50% rename from apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/SetMinimumTipScreen.kt rename to apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/mintip/MinimumTipEntryScreen.kt index f84c0794a..c498421a1 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/SetMinimumTipScreen.kt +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/mintip/MinimumTipEntryScreen.kt @@ -1,15 +1,15 @@ -package com.flipcash.app.tipping +package com.flipcash.app.userprofile.internal.mintip import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.res.stringResource -import androidx.hilt.navigation.compose.hiltViewModel -import com.flipcash.app.core.MinimumTipSource -import com.flipcash.app.tipping.internal.SetMinimumTipViewModel -import com.flipcash.features.tipping.R +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.flipcash.app.core.userprofile.UpdateProfileResult +import com.flipcash.app.core.userprofile.UpdateProfileStep +import com.flipcash.core.R import com.flipcash.shared.amountentry.AmountEntryScreen -import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.navigation.flow.rememberFlowNavigator import com.getcode.ui.components.AppBarWithTitle import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn @@ -18,31 +18,34 @@ import kotlinx.coroutines.flow.onEach /** * Minimum-tip entry (nodes 9541:10951, 9553:113170). Leaving without saving discards the entry — * there is no draft to keep, so a changed-but-abandoned amount just doesn't reach the profile. + * + * @param isLastStep whether the flow ends here, which is the only thing that decides between + * "Save" and "Next". */ @Composable -fun SetMinimumTipScreen(source: MinimumTipSource) { - val navigator = LocalCodeNavigator.current - val viewModel = hiltViewModel() +internal fun MinimumTipEntryScreen(isLastStep: Boolean) { + val flowNavigator = rememberFlowNavigator() + val viewModel = hiltViewModel() - LaunchedEffect(viewModel, source) { viewModel.onSourceResolved(source) } + LaunchedEffect(viewModel, isLastStep) { viewModel.onPositionResolved(isLastStep) } AmountEntryScreen( controller = viewModel.amountDelegate, - onConfirm = { viewModel.dispatchEvent(SetMinimumTipViewModel.Event.ConfirmRequested) }, + onConfirm = { viewModel.dispatchEvent(MinimumTipEntryViewModel.Event.ConfirmRequested) }, largeHeader = true, appBar = { AppBarWithTitle( title = stringResource(R.string.title_minimumTipEntry), titleAlignment = Alignment.CenterHorizontally, - onBackIconClicked = { navigator.pop() }, + onBackIconClicked = { flowNavigator.back() }, ) }, ) LaunchedEffect(viewModel) { viewModel.eventFlow - .filterIsInstance() - .onEach { navigator.pop() } + .filterIsInstance() + .onEach { flowNavigator.proceed() } .launchIn(this) } } diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/SetMinimumTipViewModel.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/mintip/MinimumTipEntryViewModel.kt similarity index 86% rename from apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/SetMinimumTipViewModel.kt rename to apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/mintip/MinimumTipEntryViewModel.kt index 3f8761d7a..0d627365b 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/SetMinimumTipViewModel.kt +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/mintip/MinimumTipEntryViewModel.kt @@ -1,9 +1,8 @@ -package com.flipcash.app.tipping.internal +package com.flipcash.app.userprofile.internal.mintip import androidx.lifecycle.viewModelScope -import com.flipcash.app.core.MinimumTipSource import com.flipcash.app.core.ui.ConfirmationStyle -import com.flipcash.features.tipping.R +import com.flipcash.core.R import com.flipcash.services.controllers.ProfileController import com.flipcash.services.user.UserManager import com.flipcash.shared.amountentry.AmountEntryDelegate @@ -37,19 +36,19 @@ import kotlin.math.abs * Backs the minimum-tip entry screen (nodes 9541:10951, 9553:113170) — the fee another user has to * pay to open a DM, which the profile carries as `minDmChatInitFee`. * - * Two things separate it from the send-side [TipAmountEntryViewModel]: there is no ceiling, since a - * user can ask for any amount regardless of what anyone can afford, so the preset minimum is the - * only bound and it shows as a standing hint rather than only on error; and the confirm action is a - * save, so it stays inert until the entry actually differs from what is already stored. + * Two things separate it from the send-side tip entry: there is no ceiling, since a user can ask + * for any amount regardless of what anyone can afford, so the preset minimum is the only bound and + * it shows as a standing hint rather than only on error; and the confirm action is a save, so it + * stays inert until the entry actually differs from what is already stored. */ @HiltViewModel -internal class SetMinimumTipViewModel @Inject constructor( +internal class MinimumTipEntryViewModel @Inject constructor( exchange: Exchange, private val resources: ResourceHelper, private val profileController: ProfileController, userManager: UserManager, tipPaymentDelegate: TipPaymentDelegate, -) : BaseViewModel( +) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent, ) { @@ -88,9 +87,9 @@ internal class SetMinimumTipViewModel @Inject constructor( // it needs the delegate's own state and so cannot be built before the delegate exists. private val entryChanged = MutableStateFlow(false) - // The label is the only thing the entry point changes, and the screen supplies it, so the + // The label is the only thing the step's position changes, and the screen supplies it, so the // style is a flow rather than a constant. - private val style = MutableStateFlow(styleFor(MinimumTipSource.MyAccount)) + private val style = MutableStateFlow(styleFor(isLastStep = true)) val amountDelegate = AmountEntryDelegate( exchange = exchange, @@ -180,19 +179,14 @@ internal class SetMinimumTipViewModel @Inject constructor( .launchIn(viewModelScope) } - /** Called by the screen once the route's entry point is known. */ - fun onSourceResolved(source: MinimumTipSource) { - style.value = styleFor(source) + /** Called by the screen once the step's position in the flow is known. */ + fun onPositionResolved(isLastStep: Boolean) { + style.value = styleFor(isLastStep) } - private fun styleFor(source: MinimumTipSource) = AmountEntryStyle( + private fun styleFor(isLastStep: Boolean) = AmountEntryStyle( actionLabel = AmountEntryLabel.Plain( - resources.getString( - when (source) { - MinimumTipSource.ProfileTutorial -> R.string.action_next - MinimumTipSource.MyAccount -> R.string.action_save - } - ) + resources.getString(if (isLastStep) R.string.action_save else R.string.action_next) ), actionStyle = ConfirmationStyle.Button, belowMinHint = { resources.getString(R.string.subtitle_minimumTipHint, it) }, diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/SetMinimumTipStateTest.kt b/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/mintip/MinimumTipEntryStateTest.kt similarity index 52% rename from apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/SetMinimumTipStateTest.kt rename to apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/mintip/MinimumTipEntryStateTest.kt index 0a5a5f3ce..8d9419c5a 100644 --- a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/SetMinimumTipStateTest.kt +++ b/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/mintip/MinimumTipEntryStateTest.kt @@ -1,4 +1,4 @@ -package com.flipcash.app.tipping.internal +package com.flipcash.app.userprofile.internal.mintip import com.getcode.opencode.model.financial.CurrencyCode import com.getcode.opencode.model.financial.Fiat @@ -7,18 +7,18 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue -class SetMinimumTipStateTest { +class MinimumTipEntryStateTest { - private val reduce = SetMinimumTipViewModel.updateStateForEvent + private val reduce = MinimumTipEntryViewModel.updateStateForEvent private fun apply( - event: SetMinimumTipViewModel.Event, - state: SetMinimumTipViewModel.State = SetMinimumTipViewModel.State(), + event: MinimumTipEntryViewModel.Event, + state: MinimumTipEntryViewModel.State = MinimumTipEntryViewModel.State(), ) = reduce(event)(state) @Test fun `nothing is saved by default`() { - val state = SetMinimumTipViewModel.State() + val state = MinimumTipEntryViewModel.State() assertEquals(null, state.saved) assertTrue(state.saving.isIdle) } @@ -26,45 +26,45 @@ class SetMinimumTipStateTest { @Test fun `the resolved fee lands in state`() { val fee = Fiat(5.0, CurrencyCode.USD) - assertEquals(fee, apply(SetMinimumTipViewModel.Event.SavedFeeChanged(fee)).saved) + assertEquals(fee, apply(MinimumTipEntryViewModel.Event.SavedFeeChanged(fee)).saved) } @Test fun `clearing the fee empties it again`() { val withFee = apply( - SetMinimumTipViewModel.Event.SavedFeeChanged(Fiat(5.0, CurrencyCode.USD)) + MinimumTipEntryViewModel.Event.SavedFeeChanged(Fiat(5.0, CurrencyCode.USD)) ) - assertEquals(null, apply(SetMinimumTipViewModel.Event.SavedFeeChanged(null), withFee).saved) + assertEquals(null, apply(MinimumTipEntryViewModel.Event.SavedFeeChanged(null), withFee).saved) } @Test fun `the preferred rate sets the entry currency`() { - val updated = apply(SetMinimumTipViewModel.Event.CurrencyChanged(CurrencyCode.EUR)) + val updated = apply(MinimumTipEntryViewModel.Event.CurrencyChanged(CurrencyCode.EUR)) assertEquals(CurrencyCode.EUR, updated.currency) } @Test fun `saving runs loading then success then back to idle`() { - val loading = apply(SetMinimumTipViewModel.Event.UpdateSavingState(loading = true)) + val loading = apply(MinimumTipEntryViewModel.Event.UpdateSavingState(loading = true)) assertTrue(loading.saving.loading) assertFalse(loading.saving.isIdle) val success = apply( - SetMinimumTipViewModel.Event.UpdateSavingState(success = true), + MinimumTipEntryViewModel.Event.UpdateSavingState(success = true), loading, ) assertTrue(success.saving.success) assertFalse(success.saving.loading) - assertTrue(apply(SetMinimumTipViewModel.Event.UpdateSavingState(), success).saving.isIdle) + assertTrue(apply(MinimumTipEntryViewModel.Event.UpdateSavingState(), success).saving.isIdle) } @Test fun `the navigation events leave state alone`() { - val state = SetMinimumTipViewModel.State(saved = Fiat(5.0, CurrencyCode.USD)) + val state = MinimumTipEntryViewModel.State(saved = Fiat(5.0, CurrencyCode.USD)) listOf( - SetMinimumTipViewModel.Event.ConfirmRequested, - SetMinimumTipViewModel.Event.Saved, + MinimumTipEntryViewModel.Event.ConfirmRequested, + MinimumTipEntryViewModel.Event.Saved, ).forEach { event -> assertEquals(state, apply(event, state), "Event $event should be no-op") }