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..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 @@ -134,6 +134,7 @@ fun appEntryProvider( } // User Profile Management + 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-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..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,21 +144,20 @@ 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 } @Serializable @@ -371,16 +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 } 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/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/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/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..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,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.userprofile.UpdateProfileStep 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. */ @@ -323,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), ) ) ) @@ -374,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)), ) ) ) @@ -391,12 +389,23 @@ 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), + ) + ) + ) + } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { + dispatchEvent( + Event.OpenScreen( + AppRoute.UpdateUserProfile( + origin = AppRoute.Sheets.Menu, + steps = listOf(UpdateProfileStep.MinimumTip), ) ) ) @@ -567,6 +576,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..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,14 +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. * - * 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( + val items = listOf( TutorialItem.ProfilePicture(isCompleted = profile.profilePicture != null), - TutorialItem.MinimumTip(), + 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 f0000ca28..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 @@ -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,14 @@ 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 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 1c9f63c37..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,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.userprofile.UpdateProfileStep import com.flipcash.app.myaccount.internal.myaccount.MyAccountScreen import com.flipcash.app.myaccount.internal.myaccount.MyAccountScreenViewModel import com.flipcash.core.R @@ -50,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) @@ -65,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) @@ -81,14 +77,26 @@ fun MyAccountScreen() { navigator.push( AppRoute.UpdateUserProfile( origin = AppRoute.Menu.MyAccount, - nameSource = DisplayNameSource.MyAccount, - includeName = false, - includePhoto = true, + steps = listOf(UpdateProfileStep.Photo), ) ) }.launchIn(this) } + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { + navigator.push( + AppRoute.UpdateUserProfile( + origin = AppRoute.Menu.MyAccount, + steps = listOf(UpdateProfileStep.MinimumTip), + ) + ) + } + .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/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/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/mintip/MinimumTipEntryScreen.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/mintip/MinimumTipEntryScreen.kt new file mode 100644 index 000000000..c498421a1 --- /dev/null +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/mintip/MinimumTipEntryScreen.kt @@ -0,0 +1,51 @@ +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.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.flow.rememberFlowNavigator +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. + * + * @param isLastStep whether the flow ends here, which is the only thing that decides between + * "Save" and "Next". + */ +@Composable +internal fun MinimumTipEntryScreen(isLastStep: Boolean) { + val flowNavigator = rememberFlowNavigator() + val viewModel = hiltViewModel() + + LaunchedEffect(viewModel, isLastStep) { viewModel.onPositionResolved(isLastStep) } + + AmountEntryScreen( + controller = viewModel.amountDelegate, + onConfirm = { viewModel.dispatchEvent(MinimumTipEntryViewModel.Event.ConfirmRequested) }, + largeHeader = true, + appBar = { + AppBarWithTitle( + title = stringResource(R.string.title_minimumTipEntry), + titleAlignment = Alignment.CenterHorizontally, + onBackIconClicked = { flowNavigator.back() }, + ) + }, + ) + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { flowNavigator.proceed() } + .launchIn(this) + } +} diff --git a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/mintip/MinimumTipEntryViewModel.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/mintip/MinimumTipEntryViewModel.kt new file mode 100644 index 000000000..0d627365b --- /dev/null +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/mintip/MinimumTipEntryViewModel.kt @@ -0,0 +1,214 @@ +package com.flipcash.app.userprofile.internal.mintip + +import androidx.lifecycle.viewModelScope +import com.flipcash.app.core.ui.ConfirmationStyle +import com.flipcash.core.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 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 MinimumTipEntryViewModel @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 step's position changes, and the screen supplies it, so the + // style is a flow rather than a constant. + private val style = MutableStateFlow(styleFor(isLastStep = true)) + + 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 step's position in the flow is known. */ + fun onPositionResolved(isLastStep: Boolean) { + style.value = styleFor(isLastStep) + } + + private fun styleFor(isLastStep: Boolean) = AmountEntryStyle( + actionLabel = AmountEntryLabel.Plain( + resources.getString(if (isLastStep) R.string.action_save else R.string.action_next) + ), + 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/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/mintip/MinimumTipEntryStateTest.kt b/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/mintip/MinimumTipEntryStateTest.kt new file mode 100644 index 000000000..8d9419c5a --- /dev/null +++ b/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/mintip/MinimumTipEntryStateTest.kt @@ -0,0 +1,72 @@ +package com.flipcash.app.userprofile.internal.mintip + +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 MinimumTipEntryStateTest { + + private val reduce = MinimumTipEntryViewModel.updateStateForEvent + + private fun apply( + event: MinimumTipEntryViewModel.Event, + state: MinimumTipEntryViewModel.State = MinimumTipEntryViewModel.State(), + ) = reduce(event)(state) + + @Test + fun `nothing is saved by default`() { + val state = MinimumTipEntryViewModel.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(MinimumTipEntryViewModel.Event.SavedFeeChanged(fee)).saved) + } + + @Test + fun `clearing the fee empties it again`() { + val withFee = apply( + MinimumTipEntryViewModel.Event.SavedFeeChanged(Fiat(5.0, CurrencyCode.USD)) + ) + assertEquals(null, apply(MinimumTipEntryViewModel.Event.SavedFeeChanged(null), withFee).saved) + } + + @Test + fun `the preferred rate sets the entry currency`() { + 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(MinimumTipEntryViewModel.Event.UpdateSavingState(loading = true)) + assertTrue(loading.saving.loading) + assertFalse(loading.saving.isIdle) + + val success = apply( + MinimumTipEntryViewModel.Event.UpdateSavingState(success = true), + loading, + ) + assertTrue(success.saving.success) + assertFalse(success.saving.loading) + + assertTrue(apply(MinimumTipEntryViewModel.Event.UpdateSavingState(), success).saving.isIdle) + } + + @Test + fun `the navigation events leave state alone`() { + val state = MinimumTipEntryViewModel.State(saved = Fiat(5.0, CurrencyCode.USD)) + listOf( + MinimumTipEntryViewModel.Event.ConfirmRequested, + MinimumTipEntryViewModel.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) } } } /**