diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index f62000ffa..1fc31becc 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -261,7 +261,9 @@ internal class ChatViewModel @Inject constructor( // tip (from the tip payment delegate); a contact DM swipes to *send* with no minimum. private fun amountStyle(isTip: Boolean) = AmountEntryStyle( actionLabel = AmountEntryLabel.Plain( - resources.getString(R.string.action_swipeToSend) + resources.getString( + if (isTip) R.string.action_swipeToTip else R.string.action_swipeToSend + ) ), actionStyle = ConfirmationStyle.Slide, infoHint = { resources.getString(R.string.subtitle_sendHint, it) }, @@ -269,6 +271,13 @@ internal class ChatViewModel @Inject constructor( belowMinHint = if (isTip) { { min -> resources.getString(R.string.subtitle_tipHintMinimum, min) } } else null, + // A tip's ceiling is only the sender's own balance; the minimum is the recipient's rule + // and the one worth stating up front, so it holds the hint line for the whole entry. + standingHint = if (isTip) { + AmountEntryStyle.StandingHint.Floor + } else { + AmountEntryStyle.StandingHint.Ceiling + }, ) private val isTipFlow = stateFlow @@ -669,7 +678,9 @@ internal class ChatViewModel @Inject constructor( val minimum = minAmountFlow.value if (minimum != null && amount.valueLessThan(minimum)) { dispatchEvent(Event.SendStateUpdated()) - BottomBarManager.showAlert( + // Info, not alert: nothing has failed and nothing is being destroyed — + // the entry is just under the recipient's floor and needs raising. + BottomBarManager.showInfo( title = resources.getString(R.string.error_title_tipMinimum, minimum.formatted()), message = resources.getString(R.string.error_description_tipMinimum), ) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/ChatAmountEntryScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/ChatAmountEntryScreen.kt index 00c0c689f..36b97d7e3 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/ChatAmountEntryScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/ChatAmountEntryScreen.kt @@ -59,6 +59,10 @@ internal fun ChatAmountEntryContent( controller = amountDelegate, onConfirm = onConfirm, onChangeCurrency = { navigator.push(AppRoute.Main.RegionSelection) }, + // Left-aligned v2 header, per the updated design: the amount sits under the app bar at + // the screen inset with its hint beneath, and the currency flag goes away with it — the + // send is denominated in the preferred currency, changed from settings rather than here. + largeHeader = true, appBar = { AppBarWithTitle( // Same centred pill as the give screen — declare the centring rather than leaning on 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 1a1f7c728..348300459 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 @@ -97,14 +97,18 @@ class AmountEntryDelegate( val isOverMax = max != null && !delegateState.isEmpty && Fiat(delegateState.enteredAmount, max.currencyCode).valueGreaterThan(max) + // The floor as a standing hint rather than only an error: it tells the user the rule + // before they break it. Available when there is no ceiling to describe, or when the style + // says the floor is the more useful of the two. + val hasFloorHint = min != null && currentStyle.belowMinHint != null + val floorStands = hasFloorHint && + (max == null || currentStyle.standingHint == AmountEntryStyle.StandingHint.Floor) + val hint = when { isBelowMin -> AmountEntryHint.Error(currentStyle.belowMinHint!!(min!!.formatted())) isOverMax -> AmountEntryHint.Error(currentStyle.overMaxHint(max!!.formatted())) + floorStands -> AmountEntryHint.Info(currentStyle.belowMinHint!!(min!!.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 } diff --git a/apps/flipcash/shared/amount-entry/src/main/kotlin/com/flipcash/shared/amountentry/AmountEntryStyle.kt b/apps/flipcash/shared/amount-entry/src/main/kotlin/com/flipcash/shared/amountentry/AmountEntryStyle.kt index ccde493bd..784bb1dcb 100644 --- a/apps/flipcash/shared/amount-entry/src/main/kotlin/com/flipcash/shared/amountentry/AmountEntryStyle.kt +++ b/apps/flipcash/shared/amount-entry/src/main/kotlin/com/flipcash/shared/amountentry/AmountEntryStyle.kt @@ -9,4 +9,20 @@ data class AmountEntryStyle( val infoHint: (maxFormatted: String) -> String = { "" }, val overMaxHint: (maxFormatted: String) -> String = { "" }, val belowMinHint: ((minFormatted: String) -> String)? = null, -) + val standingHint: StandingHint = StandingHint.Ceiling, +) { + /** + * Which bound the resting hint describes when the entry has both a floor and a ceiling. + * Errors are unaffected — an amount outside either bound still reports the bound it broke. + */ + enum class StandingHint { + /** "Enter up to $X" — the usual guidance, since the ceiling is what most flows constrain. */ + Ceiling, + + /** + * "Minimum tip $X" — for flows where the floor is the rule the user is likely to break and + * the ceiling is incidental (a tip chat's ceiling is just the sender's own balance). + */ + Floor, + } +} 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 dde924aae..f52d771bd 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 @@ -415,6 +415,61 @@ class AmountEntryDelegateTest { assertIs(delegate.config.value.hint) } + @Test + fun `standing hint Floor states the minimum even when a maximum exists`() = runTest { + val max = MutableStateFlow(Fiat(100.0, CurrencyCode.USD)) + val min = MutableStateFlow(Fiat(5.0, CurrencyCode.USD)) + val delegate = createDelegate( + style = AmountEntryStyle( + actionLabel = AmountEntryLabel.Plain("Swipe to Tip"), + infoHint = { "Up to $it" }, + overMaxHint = { "Over $it" }, + belowMinHint = { "Min is $it" }, + standingHint = AmountEntryStyle.StandingHint.Floor, + ), + maxAmount = max, + minimumAmount = min, + ) + delegate.onCurrencyChanged(usd) + + // Resting, and at a valid amount, the floor holds the line rather than the ceiling. + val resting = delegate.config.value.hint + assertIs(resting) + assertTrue(resting.text.startsWith("Min is")) + + delegate.onNumber(9) + val valid = delegate.config.value.hint + assertIs(valid) + assertTrue(valid.text.startsWith("Min is")) + + // Errors still report the bound that was actually broken. + delegate.onBackspace() + delegate.onNumber(2) + assertIs(delegate.config.value.hint) + } + + @Test + fun `standing hint Ceiling is the default and keeps the maximum`() = runTest { + val max = MutableStateFlow(Fiat(100.0, CurrencyCode.USD)) + val min = MutableStateFlow(Fiat(5.0, CurrencyCode.USD)) + val delegate = createDelegate( + style = AmountEntryStyle( + actionLabel = AmountEntryLabel.Plain("Next"), + infoHint = { "Up to $it" }, + overMaxHint = { "Over $it" }, + belowMinHint = { "Min is $it" }, + ), + maxAmount = max, + minimumAmount = min, + ) + delegate.onCurrencyChanged(usd) + delegate.onNumber(9) + + val hint = delegate.config.value.hint + assertIs(hint) + assertTrue(hint.text.startsWith("Up to")) + } + // --------------------------------------------------------------- // Config derivation — confirmEnabled // ---------------------------------------------------------------