From ec7c3cf12e8dccf6f271918d9e7e7cdd302f1d9b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 28 Aug 2026 15:37:22 -0400 Subject: [PATCH] feat(messenger): left-align the in-chat amount entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tip and cash DM screens used the centred amount field with its currency flag. The updated design puts the amount at the screen inset under the app bar with the hint beneath it — the layout AmountEntryScreen already renders behind `largeHeader`, and the one the minimum-tip entry uses. The chat screen opts in; no new layout code. Setting a tip needs the recipient's minimum stated somewhere, and the flag was not carrying it. A tip chat has both bounds, and the delegate has always described the ceiling, so the "enter up to" hint held the line while the floor — the rule the sender is likely to break — only appeared as an error after they broke it. AmountEntryStyle.standingHint lets a flow say which bound the resting hint describes; the chat's tip style picks the floor. Everything else keeps the ceiling by default, and errors are unchanged: an amount outside either bound still reports the bound it broke. The chat's below-minimum prompt also becomes showInfo rather than showAlert. Nothing has failed and nothing is being destroyed — the entry is under the recipient's floor and needs raising. --- .../app/messenger/internal/ChatViewModel.kt | 15 ++++- .../screens/cash/ChatAmountEntryScreen.kt | 4 ++ .../shared/amountentry/AmountEntryDelegate.kt | 12 ++-- .../shared/amountentry/AmountEntryStyle.kt | 18 +++++- .../amountentry/AmountEntryDelegateTest.kt | 55 +++++++++++++++++++ 5 files changed, 97 insertions(+), 7 deletions(-) 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 f62000ffa4..1fc31becc5 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 00c0c689fb..36b97d7e30 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 1a1f7c728b..348300459d 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 ccde493bd6..784bb1dcb8 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 dde924aae4..f52d771bd0 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 // ---------------------------------------------------------------