Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -261,14 +261,23 @@ 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) },
overMaxHint = { resources.getString(R.string.subtitle_sendHintLimitExceeded, it) },
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
Expand Down Expand Up @@ -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),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,61 @@ class AmountEntryDelegateTest {
assertIs<AmountEntryHint.Error>(delegate.config.value.hint)
}

@Test
fun `standing hint Floor states the minimum even when a maximum exists`() = runTest {
val max = MutableStateFlow<Fiat?>(Fiat(100.0, CurrencyCode.USD))
val min = MutableStateFlow<Fiat?>(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<AmountEntryHint.Info>(resting)
assertTrue(resting.text.startsWith("Min is"))

delegate.onNumber(9)
val valid = delegate.config.value.hint
assertIs<AmountEntryHint.Info>(valid)
assertTrue(valid.text.startsWith("Min is"))

// Errors still report the bound that was actually broken.
delegate.onBackspace()
delegate.onNumber(2)
assertIs<AmountEntryHint.Error>(delegate.config.value.hint)
}

@Test
fun `standing hint Ceiling is the default and keeps the maximum`() = runTest {
val max = MutableStateFlow<Fiat?>(Fiat(100.0, CurrencyCode.USD))
val min = MutableStateFlow<Fiat?>(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<AmountEntryHint.Info>(hint)
assertTrue(hint.text.startsWith("Up to"))
}

// ---------------------------------------------------------------
// Config derivation — confirmEnabled
// ---------------------------------------------------------------
Expand Down
Loading