From d71cae9fd71d33924e7dc7b158fe8ed8682700df Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 24 Sep 2026 19:16:45 -0400 Subject: [PATCH 1/4] feat(chat): offer All Currencies as a group's balance requirement The create form could only set a minimum balance in one token, and it opened on the largest holding the creator had. StartChat already accepts an empty mints list as "any currency", which the server measures against every holding added up in USD, USDF included. GroupAccess.evaluate sums for that case too, so only the create UI was missing. The draft's mint and mintChosen are replaced by a GroupCurrency, either All or Specific(mint), and the form opens on All. For All, rules carries MinimumBalance(amount, emptyList()), and the self-check runs through the same GroupAccess predicate. GroupDraft holds the GroupCurrency, so an All draft and a one-token draft never share an idempotency key. The currency sheet puts an All Currencies card above the token list (node 10370:997), with a "Specific Currency" label over the tokens. The card's subtitle gives the total and whether it meets the chosen amount, in the error colour when it does not (node 10372:1126). The card and the tokens share one selection, so no token is marked while All is chosen. The form's currency row shows the coins glyph and "All Currencies" (node 10364:1059). The heading used to switch from "Minimum Balance Required" to "Balance Requirement" once a mint was picked. With no default mint left to tell apart from a pick, it now switches once a token is picked or an amount is set. That keeps nodes 10127:118057 and 10127:118194 as they were and matches 10364:1059. An All rule names no token, so the "You Need More %1$s" refusal gets a title of its own for that case. --- .../core/src/main/res/values/strings.xml | 11 ++ .../tipping/internal/CreateGroupViewModel.kt | 131 ++++++------- .../tipping/internal/GroupCreateAttempt.kt | 8 +- .../app/tipping/internal/GroupCurrency.kt | 25 +++ .../internal/screens/NewGroupFormScreen.kt | 56 +++--- .../screens/SelectGroupCurrencySheet.kt | 175 +++++++++++++++++- .../tipping/internal/CreateGroupStateTest.kt | 116 ++++++++---- .../internal/CreateGroupViewModelErrorTest.kt | 47 ++++- .../internal/GroupCreateAttemptTest.kt | 22 ++- 9 files changed, 461 insertions(+), 130 deletions(-) create mode 100644 apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/GroupCurrency.kt diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 929df5953a..47a29756be 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1048,6 +1048,17 @@ call rather than surfacing RULES_NOT_SATISFIED as a failure. --> You Need More %1$s You have to meet the balance requirement you set to create this group + + You Need a Higher Balance + + All Currencies + + You have %1$s total · meets %2$s + + You have %1$s total · need %2$s + + You have %1$s total + Specific Currency This Group Name is Not Allowed These Rules Aren\'t Allowed Try a different balance requirement diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModel.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModel.kt index 53d33043d6..1bd07403a2 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModel.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModel.kt @@ -33,7 +33,7 @@ import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.Rate import com.getcode.opencode.model.financial.Token import com.getcode.opencode.model.financial.TokenWithBalance -import com.getcode.solana.keys.Mint +import com.getcode.opencode.model.financial.sum import com.getcode.util.resources.ContentReader import com.getcode.util.resources.ResourceHelper import com.getcode.view.BaseViewModel @@ -62,13 +62,13 @@ internal val BalancePresets: List = listOf(Fiat(10), Fiat(50), Fiat(100)) * The draft behind creating a public group — section 10153:22901 — shared across the flow's steps. * * One view model for all four steps rather than one each, because they are four views of a single - * draft: the title and picture are entered on the form, the mint on the currency sheet, the amount + * draft: the title and picture are entered on the form, the currency on its sheet, the amount * on a chip or the custom keypad, and the invite step shares what the draft became. Holding it here * is also what gives an attempt's idempotency key a lifetime longer than a single screen — see * [GroupCreateAttempt]. * * Two rules are enforced before the call rather than after it. The creator has to satisfy the rules - * they are setting, so Create stays inert while the selected mint's balance is under the amount — + * they are setting, so Create stays inert while the balance the rule measures is under the amount — * `StartChat` would answer `RULES_NOT_SATISFIED`, which is a worse way to learn it. And the picture * has to be uploaded and `READY` first, because `GroupChatParameters.picture` is a blob id, not * bytes. Both server results are still handled: a balance can move between the check and the call. @@ -99,13 +99,8 @@ internal class CreateGroupViewModel @Inject constructor( val balances: List = emptyList(), /** The preferred rate, for reading a custom keypad entry back into USD. */ val rate: Rate = Rate.oneToOne, - val mint: Mint? = null, - /** - * Whether the creator has picked the mint, rather than inherited the one the form opened - * on. Not `mint != null`: the form always opens with one named, so nothing about the mint - * itself distinguishes a default from a choice. - */ - val mintChosen: Boolean = false, + /** What the requirement is measured in. The form opens on every holding added up. */ + val currency: GroupCurrency = GroupCurrency.All, val amount: Fiat? = null, val processingState: LoadingSuccessState = LoadingSuccessState(), /** The chat `StartChat` returned. Non-null is the whole precondition for the invite step. */ @@ -117,9 +112,14 @@ internal class CreateGroupViewModel @Inject constructor( val hasTitle: Boolean get() = title.isNotEmpty() - /** The selected mint's metadata, which is where the row's name and image come from. */ + /** + * The selected token's metadata, which is where the row's name and image come from. Null + * for [GroupCurrency.All], which names no token. + */ val token: Token? - get() = mint?.let { selected -> balances.firstOrNull { it.token.address == selected }?.token } + get() = (currency as? GroupCurrency.Specific)?.let { selected -> + balances.firstOrNull { it.token.address == selected.mint }?.token + } /** * What the form and its errors call the requirement's currency — `$BadBoys` in node @@ -129,26 +129,38 @@ internal class CreateGroupViewModel @Inject constructor( * list this mint was picked from already renders * ([com.getcode.opencode.model.financial.TokenWithBalance.displayName]), so the symbol * would name the same holding two different ways one screen apart. + * + * Null for [GroupCurrency.All]: there is no one currency to name, and the error that reads + * this has a wording of its own for that case. */ val currencyName: String? get() = token?.name + /** + * Every holding added up, in USD — what an any-currency rule is measured against, and what + * the currency sheet's header card reports. + */ + val totalBalance: Fiat + get() = balances.map { it.balance }.sum() + + /** + * Whether the heading has moved from asking for a requirement to naming one. + * + * Node 10127:118057 still asks "Minimum Balance Required" with the title typed and nothing + * else touched; node 10127:118194 reads "Balance Requirement" on a picked token with no + * amount yet, and node 10364:1059 reads it on All Currencies once an amount is set. + */ + val requirementStarted: Boolean + get() = currency is GroupCurrency.Specific || amount != null + /** * The rules the draft describes, once it describes any. * - * Listener only, and exactly one mint. A listener requirement is what gates *entry*, which - * is what this design sets; `speaker` is left empty rather than null because - * [ChatRules] holds both as lists. + * Listener only. A listener requirement is what gates *entry*, which is what this design + * sets; `speaker` is left empty rather than null because [ChatRules] holds both as lists. */ val rules: ChatRules? - get() { - val mint = mint ?: return null - val amount = amount ?: return null - return ChatRules( - listener = listOf(ChatRuleRequirement.MinimumBalance(amount, listOf(mint))), - speaker = emptyList(), - ) - } + get() = amount?.let { rulesFor(currency, it) } /** * The creator measured against their own rule, by the same predicate the join gate uses. @@ -157,17 +169,24 @@ internal class CreateGroupViewModel @Inject constructor( * the question being asked is exactly the one the gate asks of a stranger. */ val access: GroupAccess? - get() = rules?.let { - GroupAccess.evaluate( - isMember = false, - rules = it, - balances = balances, - // [rules] only ever builds a minimum balance — the form has no staff control — - // so nothing here reads this. It is passed rather than defaulted so that adding - // such a control has to come back and answer the question. - isStaff = false, - ) - } + get() = rules?.let(::accessFor) + + /** + * Whether the total meets the amount — the header card's `meets` or `need`, which it shows + * whichever currency is selected. Null until there is an amount to measure against. + */ + val allCurrenciesSatisfied: Boolean? + get() = amount?.let { accessFor(rulesFor(GroupCurrency.All, it)) is GroupAccess.Eligible } + + private fun accessFor(rules: ChatRules): GroupAccess = GroupAccess.evaluate( + isMember = false, + rules = rules, + balances = balances, + // [rulesFor] only ever builds a minimum balance — the form has no staff control — so + // nothing here reads this. It is passed rather than defaulted so that adding such a + // control has to come back and answer the question. + isStaff = false, + ) /** Null rules can't be unsatisfied — an incomplete draft is blocked by [canCreate] instead. */ val selfSatisfied: Boolean @@ -199,8 +218,8 @@ internal class CreateGroupViewModel @Inject constructor( data class OnBalancesChanged(val balances: List) : Event data class OnRateChanged(val rate: Rate) : Event - /** A mint was chosen on the currency sheet (node 10127:118100). */ - data class OnMintSelected(val mint: Mint) : Event + /** The header card or a token was chosen on the currency sheet (node 10370:997). */ + data class OnCurrencySelected(val currency: GroupCurrency) : Event /** A preset chip, or a confirmed custom keypad entry. Always USD. */ data class OnAmountSelected(val amount: Fiat) : Event @@ -330,7 +349,6 @@ internal class CreateGroupViewModel @Inject constructor( val state = stateFlow.value if (!state.processingState.isIdle) return - val mint = state.mint ?: return val amount = state.amount ?: return if (!state.hasTitle) return @@ -344,7 +362,7 @@ internal class CreateGroupViewModel @Inject constructor( val draft = GroupDraft( title = state.title, picture = state.image.dataOrNull, - mint = mint, + currency = state.currency, amount = amount, ) @@ -430,7 +448,11 @@ internal class CreateGroupViewModel @Inject constructor( private fun announceNotSelfSatisfied(currencyName: String?) { BottomBarManager.showAlert( - title = resources.getString(R.string.error_title_groupRuleNotSelfSatisfied, currencyName.orEmpty()), + title = if (currencyName != null) { + resources.getString(R.string.error_title_groupRuleNotSelfSatisfied, currencyName) + } else { + resources.getString(R.string.error_title_groupRuleNotSelfSatisfiedTotal) + }, message = resources.getString(R.string.error_description_groupRuleNotSelfSatisfied), ) } @@ -497,6 +519,12 @@ internal class CreateGroupViewModel @Inject constructor( } companion object { + /** One listener minimum balance of [amount], in whatever [currency] names. */ + private fun rulesFor(currency: GroupCurrency, amount: Fiat) = ChatRules( + listener = listOf(ChatRuleRequirement.MinimumBalance(amount, currency.mints)), + speaker = emptyList(), + ) + @VisibleForTesting internal val updateStateForEvent: (Event) -> (State.() -> State) = { event -> when (event) { @@ -518,30 +546,9 @@ internal class CreateGroupViewModel @Inject constructor( state.copy(image = Loadable.Loading(), imageMimeType = "") } - is Event.OnBalancesChanged -> { state -> - state.copy( - balances = event.balances, - // The form opens with a mint already named (node 10127:118057), so one is - // seated here rather than left for the sheet. The largest holding is the - // one the creator can actually set a requirement in — picking anything - // else would open the form on a rule its own author fails. - // - // Balances under a cent are skipped because the currency sheet skips them - // too (`SelectTokenViewModel` filters `TokenPurpose.Balance` on - // `hasDisplayableValue`). Seating from a wider list than the picker offers - // would name a mint the picker cannot show, leaving no way back to it. A - // wallet holding only dust therefore seats nothing and shows the sheet's - // own "Select Currency" — an empty picker under a named currency would be - // the worse of the two. - mint = state.mint ?: event.balances - .filter { it.balance.hasDisplayableValue } - .maxByOrNull { it.balance.quarks } - ?.token?.address, - ) - } + is Event.OnBalancesChanged -> { state -> state.copy(balances = event.balances) } is Event.OnRateChanged -> { state -> state.copy(rate = event.rate) } - is Event.OnMintSelected -> - { state -> state.copy(mint = event.mint, mintChosen = true) } + is Event.OnCurrencySelected -> { state -> state.copy(currency = event.currency) } is Event.OnAmountSelected -> { state -> state.copy(amount = event.amount) } Event.ConfirmCustomAmount -> { state -> state } Event.CreateRequested -> { state -> state } diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/GroupCreateAttempt.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/GroupCreateAttempt.kt index 264692a2f3..4764314d03 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/GroupCreateAttempt.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/GroupCreateAttempt.kt @@ -3,7 +3,6 @@ package com.flipcash.app.tipping.internal import android.net.Uri import com.flipcash.services.models.chat.IdempotencyKey import com.getcode.opencode.model.financial.Fiat -import com.getcode.solana.keys.Mint import com.flipcash.services.models.chat.BlobId /** @@ -16,8 +15,11 @@ internal data class GroupDraft( val title: String, /** The re-encoded local copy, before upload. Null when the group has no picture. */ val picture: Uri?, - /** The mint the balance requirement is denominated in. */ - val mint: Mint, + /** + * What the balance requirement is measured in. [GroupCurrency.All] is a value of its own rather + * than a missing mint, so an any-currency draft and a one-token draft never share a key. + */ + val currency: GroupCurrency, /** The required balance, in USD — the currency [com.flipcash.shared.chat.GroupAccess] compares in. */ val amount: Fiat, ) diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/GroupCurrency.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/GroupCurrency.kt new file mode 100644 index 0000000000..596ea29171 --- /dev/null +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/GroupCurrency.kt @@ -0,0 +1,25 @@ +package com.flipcash.app.tipping.internal + +import com.getcode.solana.keys.Mint + +/** + * What a group's balance requirement is measured in — the currency sheet's header card (node + * 10370:997) or one of the tokens under it (node 10372:1015). + * + * [All] is not the absence of a choice. `MinimumBalanceRequirement.mints` left empty means "any + * currency", and the server measures it against every holding added up in USD, USDF included; so it + * is a rule in its own right and the one the form opens on. + */ +internal sealed interface GroupCurrency { + + /** The mints `MinimumBalanceRequirement` carries: none for [All], the one for [Specific]. */ + val mints: List + + data object All : GroupCurrency { + override val mints: List = emptyList() + } + + data class Specific(val mint: Mint) : GroupCurrency { + override val mints: List = listOf(mint) + } +} diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/NewGroupFormScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/NewGroupFormScreen.kt index 7c7fe7b4a4..76b83ba82b 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/NewGroupFormScreen.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/NewGroupFormScreen.kt @@ -47,6 +47,7 @@ import com.flipcash.app.core.data.isLoaded import com.flipcash.app.core.ui.DisplayTextInput import com.flipcash.app.tipping.internal.BalancePresets import com.flipcash.app.tipping.internal.CreateGroupViewModel +import com.flipcash.app.tipping.internal.GroupCurrency import com.flipcash.features.tipping.R import com.getcode.navigation.flow.rememberFlowNavigator import com.getcode.opencode.model.financial.Fiat @@ -294,12 +295,10 @@ private fun IdentityCard( } /** - * The balance rule: which mint, and how much of it. + * The balance rule: which currency, and how much of it. * - * The heading changes once the creator picks the mint, not once an amount is: node 10127:118057 - * still asks for a "Minimum Balance Required" with the title already filled in and the form's - * opening mint untouched, and node 10127:118194 reads "Balance Requirement" with the mint changed - * and no amount yet. + * The heading asks for a "Minimum Balance Required" until the creator has set part of the rule — + * see [CreateGroupViewModel.State.requirementStarted] for the frames that pin down when. */ @Composable private fun RequirementSection( @@ -315,7 +314,7 @@ private fun RequirementSection( Text( modifier = Modifier.padding(horizontal = CodeTheme.dimens.grid.x3), text = stringResource( - if (!state.mintChosen) { + if (!state.requirementStarted) { R.string.title_minimumBalanceRequired } else { R.string.title_balanceRequirement @@ -398,30 +397,38 @@ private fun MintRow(state: CreateGroupViewModel.State, onClick: () -> Unit) { horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), verticalAlignment = Alignment.CenterVertically, ) { - val token = state.token - AsyncImage( - model = ImageRequest.Builder(LocalPlatformContext.current) - .data(token?.imageUrl) - .build(), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .size(MintIconSize) - .clip(CircleShape) - .background(White10), - ) + val allCurrencies = state.currency == GroupCurrency.All + if (allCurrencies) { + // Node 10364:1059 — the sheet's card glyph, scaled to the row. + AllCurrenciesIcon(discSize = MintIconSize, iconSize = AllCurrenciesGlyphSize) + } else { + AsyncImage( + model = ImageRequest.Builder(LocalPlatformContext.current) + .data(state.token?.imageUrl) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(MintIconSize) + .clip(CircleShape) + .background(White10), + ) + } - // The form seats a mint before it is drawn, so node 10127:118014 names one in the empty - // state and this row normally has a name to show. A wallet holding nothing has no mint to - // seat and no frame in the design either, so the row falls back to the sheet's own title - // rather than rendering a blank, tappable line. + // A picked token whose balance has since left the list has no name to show, so the row + // falls back to the sheet's own title rather than rendering a blank, tappable line. + val name = if (allCurrencies) { + stringResource(R.string.title_allCurrencies) + } else { + state.currencyName + } Text( - text = state.currencyName ?: stringResource(R.string.title_selectCurrency), + text = name ?: stringResource(R.string.title_selectCurrency), style = CodeTheme.typography.textMedium.copy( fontSize = 16.sp, fontWeight = FontWeight.SemiBold, ), - color = if (token == null) White50 else CodeTheme.colors.textMain, + color = if (name == null) White50 else CodeTheme.colors.textMain, ) Icon( @@ -456,6 +463,7 @@ private fun AmountChip( private val AvatarSize = 74.dp private val CameraIconSize = 40.dp private val MintIconSize = 20.dp +private val AllCurrenciesGlyphSize = 12.dp private val ChipHeight = 60.dp private val LabelStyle diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/SelectGroupCurrencySheet.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/SelectGroupCurrencySheet.kt index db8556def1..2808b7b7aa 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/SelectGroupCurrencySheet.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/SelectGroupCurrencySheet.kt @@ -1,14 +1,33 @@ package com.flipcash.app.tipping.internal.screens import android.os.Parcelable +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.chat.NewGroupStep @@ -17,24 +36,34 @@ import com.flipcash.app.core.ui.TokenBalanceStyle import com.flipcash.app.core.ui.TokenSelectionStyle import com.flipcash.app.core.ui.rememberTokenBalanceRowStyling import com.flipcash.app.tipping.internal.CreateGroupViewModel +import com.flipcash.app.tipping.internal.GroupCurrency import com.flipcash.app.tokens.ui.SelectTokenViewModel import com.flipcash.app.tokens.ui.TokenList import com.flipcash.app.tokens.ui.TokenListPresentation import com.flipcash.features.tipping.R import com.getcode.navigation.flow.rememberFlowNavigator +import com.getcode.opencode.model.financial.Fiat import com.getcode.theme.CodeTheme +import com.getcode.theme.White +import com.getcode.theme.White05 +import com.getcode.theme.White10 +import com.getcode.theme.White50 import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.theme.CodeScaffold /** - * Node 10127:118100 — which currency the group's balance requirement is denominated in. + * Node 10127:118100 — which currency the group's balance requirement is denominated in, with the + * All Currencies card over the token list (node 10370:997). * * The list comes from [SelectTokenViewModel] but the *selection* does not: that view model's own * selected mint is the wallet's global one, and choosing a currency for a group being drafted must * not move the wallet off the token the user is holding. So the chosen mint is read from and written * to [CreateGroupViewModel]'s draft, and the list is driven with [TokenPurpose.Balance] — the one * purpose that isn't a `TriggersChange` and so leaves the global selection alone. + * + * The card and the tokens are one selection: picking either replaces the other, and while All is + * chosen no token is marked. */ @Composable internal fun SelectGroupCurrencySheet(viewModel: CreateGroupViewModel) { @@ -66,11 +95,47 @@ internal fun SelectGroupCurrencySheet(viewModel: CreateGroupViewModel) { balanceDisplayStyle = TokenBalanceStyle.Pill(), selectionStyle = TokenSelectionStyle.Checkbox, ), - selectedToken = state.mint, + selectedToken = (state.currency as? GroupCurrency.Specific)?.mint, showSelections = true, presentation = TokenListPresentation.Default, + header = { + Column { + AllCurrenciesCard( + modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), + selected = state.currency == GroupCurrency.All, + total = state.totalBalance, + amount = state.amount, + satisfied = state.allCurrenciesSatisfied, + onClick = { + viewModel.dispatchEvent( + CreateGroupViewModel.Event.OnCurrencySelected(GroupCurrency.All) + ) + flowNavigator.back() + }, + ) + + // A label over nothing reads as a list that failed to load. + if (!tokenState.tokens.isNullOrEmpty()) { + Text( + modifier = Modifier.padding( + start = CodeTheme.dimens.inset, + end = CodeTheme.dimens.inset, + top = CodeTheme.dimens.grid.x5, + bottom = CodeTheme.dimens.grid.x1, + ), + text = stringResource(R.string.title_specificCurrency), + style = CodeTheme.typography.textSmall, + color = White50, + ) + } + } + }, onTokenSelected = { token -> - viewModel.dispatchEvent(CreateGroupViewModel.Event.OnMintSelected(token.address)) + viewModel.dispatchEvent( + CreateGroupViewModel.Event.OnCurrencySelected( + GroupCurrency.Specific(token.address) + ) + ) flowNavigator.back() }, ) @@ -84,3 +149,107 @@ internal fun SelectGroupCurrencySheet(viewModel: CreateGroupViewModel) { ) } } + +/** + * The All Currencies card — node 10370:997 selected, 10372:1015 with a token picked instead, and + * 10372:1126 when the total falls short. + * + * The subtitle reports the total against the amount whichever currency is selected, so a creator + * choosing between All and a token can see what All would come to before picking it. + */ +@Composable +private fun AllCurrenciesCard( + selected: Boolean, + total: Fiat, + amount: Fiat?, + satisfied: Boolean?, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val shape = RoundedCornerShape(CardCornerRadius) + Row( + modifier = modifier + .fillMaxWidth() + .clip(shape) + .background(White05) + .border( + width = CodeTheme.dimens.border, + color = if (selected) White50 else CardOutline, + shape = shape, + ) + .clickable(onClick = onClick) + .padding( + horizontal = CodeTheme.dimens.grid.x3, + vertical = CodeTheme.dimens.grid.x3, + ), + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + verticalAlignment = Alignment.CenterVertically, + ) { + AllCurrenciesIcon() + + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.title_allCurrencies), + style = CodeTheme.typography.textMedium.copy(fontWeight = FontWeight.SemiBold), + color = CodeTheme.colors.textMain, + ) + + val totalText = total.formatted() + Text( + text = when { + amount == null -> stringResource(R.string.subtitle_allCurrenciesTotal, totalText) + satisfied == false -> stringResource( + R.string.subtitle_allCurrenciesNeeds, + totalText, + amount.formatted(rule = Fiat.FormattingRule.Truncated), + ) + else -> stringResource( + R.string.subtitle_allCurrenciesMeets, + totalText, + amount.formatted(rule = Fiat.FormattingRule.Truncated), + ) + }, + style = CodeTheme.typography.caption, + color = if (satisfied == false) CodeTheme.colors.errorText else White50, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + // The token rows' own mark, so the card reads as one more option in the same list. + Image( + painter = painterResource( + if (selected) R.drawable.ic_checked else R.drawable.ic_unchecked + ), + contentDescription = null, + ) + } +} + +/** Node 10369:999 — the coins glyph on a grey disc, shared with the form's currency row. */ +@Composable +internal fun AllCurrenciesIcon( + modifier: Modifier = Modifier, + discSize: Dp = AllCurrenciesDiscSize, + iconSize: Dp = AllCurrenciesIconSize, +) { + Box( + modifier = modifier + .size(discSize) + .clip(CircleShape) + .background(White10), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(R.drawable.ic_coins), + contentDescription = null, + tint = CodeTheme.colors.textMain, + modifier = Modifier.size(iconSize), + ) + } +} + +private val CardCornerRadius = 12.dp +private val CardOutline = White.copy(alpha = 0.15f) +private val AllCurrenciesDiscSize = 32.dp +private val AllCurrenciesIconSize = 20.dp diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/CreateGroupStateTest.kt b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/CreateGroupStateTest.kt index 1761e7f0e5..3be5667b6a 100644 --- a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/CreateGroupStateTest.kt +++ b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/CreateGroupStateTest.kt @@ -21,9 +21,10 @@ import kotlin.test.assertTrue * What the form decides before it calls `StartChat` — nodes 10127:118014 through 10127:118237. * * Two of those decisions are contractual rather than cosmetic. The rules the draft describes must be - * a single *listener* minimum balance with exactly one mint, which is all the server accepts today; - * and the creator has to satisfy the rule they are setting, measured by the same predicate that will - * gate everyone else, or the server answers `RULES_NOT_SATISFIED` on a group they just made. + * a single *listener* minimum balance, naming either no mint (All Currencies — every holding added + * up) or exactly one; and the creator has to satisfy the rule they are setting, measured by the same + * predicate that will gate everyone else, or the server answers `RULES_NOT_SATISFIED` on a group + * they just made. */ class CreateGroupStateTest { @@ -55,47 +56,48 @@ class CreateGroupStateTest { private fun state( title: String = "Ballers", - mint: Mint? = badBoys, + currency: GroupCurrency = GroupCurrency.Specific(badBoys), amount: Fiat? = Fiat(100), balances: List = listOf(held(1, "BadBoys", 250.0)), ) = CreateGroupViewModel.State( titleFieldState = TextFieldState(title), balances = balances, - mint = mint, + currency = currency, amount = amount, ) + private fun CreateGroupViewModel.State.on(event: CreateGroupViewModel.Event) = + CreateGroupViewModel.updateStateForEvent(event)(this) + + @Test + fun `the form opens on all currencies, and balances arriving do not move it`() { + // Node 10364:1059. Seating the largest holding instead would set a narrower rule than the + // creator asked for, and one they did not pick. + val opened = CreateGroupViewModel.State() + .on(CreateGroupViewModel.Event.OnBalancesChanged(listOf(held(1, "BadBoys", 250.0)))) + + assertEquals(GroupCurrency.All, opened.currency) + assertNull(opened.token) + assertNull(opened.currencyName) + } + @Test - fun `the mint the form opens on is not a mint the creator chose`() { - val opened = CreateGroupViewModel.updateStateForEvent( - CreateGroupViewModel.Event.OnBalancesChanged(listOf(held(1, "BadBoys", 250.0))) - )(CreateGroupViewModel.State()) - - // The heading reads "Minimum Balance Required" until the creator picks the currency, and - // the form always opens with one named — so the mint alone cannot tell the two apart. - // Node 10127:118057 still asks, with the title typed and the opening mint untouched; - // node 10127:118194 reads "Balance Requirement" on the mint, before any amount. - assertEquals(badBoys, opened.mint) - assertFalse(opened.mintChosen) - - val picked = CreateGroupViewModel.updateStateForEvent( - CreateGroupViewModel.Event.OnMintSelected(badBoys) - )(opened) - - assertTrue(picked.mintChosen) + fun `picking a token replaces all currencies, and picking all replaces the token`() { + val picked = CreateGroupViewModel.State() + .on(CreateGroupViewModel.Event.OnCurrencySelected(GroupCurrency.Specific(badBoys))) + assertEquals(GroupCurrency.Specific(badBoys), picked.currency) + + val back = picked.on(CreateGroupViewModel.Event.OnCurrencySelected(GroupCurrency.All)) + assertEquals(GroupCurrency.All, back.currency) } @Test - fun `a wallet holding only dust seats no mint`() { - // The currency sheet filters `TokenPurpose.Balance` on `hasDisplayableValue`, so seating - // from a wider list would name a mint the picker cannot show and the creator could not get - // back to. Nothing seated means the row reads "Select Currency" instead. - val opened = CreateGroupViewModel.updateStateForEvent( - CreateGroupViewModel.Event.OnBalancesChanged(listOf(held(1, "BadBoys", 0.001))) - )(CreateGroupViewModel.State()) - - assertNull(opened.mint) - assertFalse(opened.mintChosen) + fun `the heading names the requirement once part of it is set`() { + // 10127:118057 asks; 10127:118194 names it on a token with no amount; 10364:1059 names it + // on All Currencies with an amount. + assertFalse(state(currency = GroupCurrency.All, amount = null).requirementStarted) + assertTrue(state(amount = null).requirementStarted) + assertTrue(state(currency = GroupCurrency.All).requirementStarted) } @Test @@ -110,11 +112,20 @@ class CreateGroupStateTest { assertEquals(listOf(badBoys), requirement.mints) } + @Test + fun `an all currencies draft names no mint`() { + val rules = state(currency = GroupCurrency.All).rules + + assertEquals(emptyList(), rules?.speaker) + val requirement = assertIs(rules?.listener?.single()) + assertEquals(Fiat(100), requirement.amount) + assertEquals(emptyList(), requirement.mints) + } + @Test fun `an incomplete draft describes no rules and cannot be created`() { - assertNull(state(mint = null).rules) assertNull(state(amount = null).rules) - assertFalse(state(mint = null).canCreate) + assertNull(state(currency = GroupCurrency.All, amount = null).rules) assertFalse(state(amount = null).canCreate) assertFalse(state(title = " ").canCreate) } @@ -146,4 +157,41 @@ class CreateGroupStateTest { assertFalse(state.selfSatisfied) assertFalse(state.canCreate) } + + @Test + fun `an all currencies rule is met by holdings added up`() { + // $60 + $50 meets $100 though neither does alone — the server sums every holding in USD. + val state = state( + currency = GroupCurrency.All, + balances = listOf(held(1, "BadBoys", 60.0), held(2, "Other", 50.0)), + ) + + assertEquals(Fiat(110.0), state.totalBalance) + assertIs(state.access) + assertTrue(state.canCreate) + assertEquals(true, state.allCurrenciesSatisfied) + } + + @Test + fun `an all currencies rule the total falls short of blocks create`() { + val state = state( + currency = GroupCurrency.All, + amount = Fiat(500), + balances = listOf(held(1, "BadBoys", 60.0), held(2, "Other", 50.0)), + ) + + assertIs(state.access) + assertFalse(state.canCreate) + assertEquals(false, state.allCurrenciesSatisfied) + } + + @Test + fun `the header card measures the total even while a token is picked`() { + // Node 10372:1015: the card still says whether All would meet the amount. + val state = state(balances = listOf(held(1, "BadBoys", 10.0), held(2, "Other", 95.0))) + + assertFalse(state.selfSatisfied) + assertEquals(true, state.allCurrenciesSatisfied) + assertNull(state(amount = null).allCurrenciesSatisfied) + } } diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModelErrorTest.kt b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModelErrorTest.kt index aaa69ce648..7d169e44fd 100644 --- a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModelErrorTest.kt +++ b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModelErrorTest.kt @@ -13,6 +13,7 @@ import com.flipcash.services.models.StartChatError import com.flipcash.services.models.chat.BlobId import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatRuleRequirement import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.IdempotencyKey import com.flipcash.services.models.chat.StartChatParameters @@ -128,7 +129,9 @@ class CreateGroupViewModelErrorTest { listOf(TokenWithBalance(token = token(1, "BadBoys"), balance = Fiat(250.0))) ) ) - dispatchEvent(CreateGroupViewModel.Event.OnMintSelected(badBoys)) + dispatchEvent( + CreateGroupViewModel.Event.OnCurrencySelected(GroupCurrency.Specific(badBoys)) + ) dispatchEvent(CreateGroupViewModel.Event.OnAmountSelected(Fiat(100))) } @@ -169,6 +172,48 @@ class CreateGroupViewModelErrorTest { assertNull(vm.stateFlow.value.created) } + /** + * An All Currencies rule names no token, so the refusal cannot say which one to get more of — + * "You Need More " with nothing after it is what the shared title would read. + */ + @Test + fun `an any-currency refusal does not name a token`() = + runTest(mainCoroutineRule.dispatcher) { + dispatchers = TestDispatchers(testScheduler) + val vm = createViewModel() + vm.completeDraft() + vm.dispatchEvent(CreateGroupViewModel.Event.OnCurrencySelected(GroupCurrency.All)) + failing(StartChatError.RulesNotSatisfied()) + + vm.dispatchEvent(CreateGroupViewModel.Event.CreateRequested) + advanceUntilIdle() + + assertTrue(titles().contains("error_title_groupRuleNotSelfSatisfiedTotal")) + } + + /** `mints = []` is the whole of what makes a rule any-currency on the wire. */ + @Test + fun `an any-currency group is created with no mints`() = + runTest(mainCoroutineRule.dispatcher) { + dispatchers = TestDispatchers(testScheduler) + val vm = createViewModel() + vm.completeDraft() + vm.dispatchEvent(CreateGroupViewModel.Event.OnCurrencySelected(GroupCurrency.All)) + + val parameters = mutableListOf() + coEvery { + chatCoordinator.create(capture(parameters), any()) + } returns Result.success(created()) + + vm.dispatchEvent(CreateGroupViewModel.Event.CreateRequested) + advanceUntilIdle() + + val rules = (parameters.single() as StartChatParameters.Group).rules + val requirement = rules?.listener?.single() as ChatRuleRequirement.MinimumBalance + assertEquals(emptyList(), requirement.mints) + assertEquals(Fiat(100), requirement.amount) + } + @Test fun `a moderated title is reported with the category the server flagged`() = runTest(mainCoroutineRule.dispatcher) { diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/GroupCreateAttemptTest.kt b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/GroupCreateAttemptTest.kt index 812880344f..a875b9de0f 100644 --- a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/GroupCreateAttemptTest.kt +++ b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/GroupCreateAttemptTest.kt @@ -23,9 +23,9 @@ class GroupCreateAttemptTest { private fun draft( title: String = "Ballers", - mint: Mint = mint(1), + currency: GroupCurrency = GroupCurrency.Specific(mint(1)), amount: Fiat = Fiat(100), - ) = GroupDraft(title = title, picture = null, mint = mint, amount = amount) + ) = GroupDraft(title = title, picture = null, currency = currency, amount = amount) /** Hands out predictable keys, so a second key is visibly a second key. */ private class Keys { @@ -54,7 +54,23 @@ class GroupCreateAttemptTest { assertNotEquals(first, attempt.keyFor(draft(title = "Ballers 2"))) assertNotEquals(first, attempt.keyFor(draft(amount = Fiat(50)))) - assertNotEquals(first, attempt.keyFor(draft(mint = mint(2)))) + assertNotEquals(first, attempt.keyFor(draft(currency = GroupCurrency.Specific(mint(2))))) + } + + /** + * Switching between All Currencies and a token changes what the server will enforce, so a key + * minted for one must not return the chat created for the other. + */ + @Test + fun `any currency and one token are different attempts`() { + val attempt = Keys().attempt + val all = attempt.keyFor(draft(currency = GroupCurrency.All)) + + assertEquals(all, attempt.keyFor(draft(currency = GroupCurrency.All))) + + val token = attempt.keyFor(draft()) + assertNotEquals(all, token) + assertNotEquals(token, attempt.keyFor(draft(currency = GroupCurrency.All))) } @Test From f52472d6d2e585cbfd01f0fc6d3cebe53484f3c3 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 24 Sep 2026 19:23:23 -0400 Subject: [PATCH 2/4] fix(tipping): widen the All Currencies card and space it from the top bar The card took the list's inset and its own on top, so it sat narrower than the row dividers. --- .../app/tipping/internal/screens/SelectGroupCurrencySheet.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/SelectGroupCurrencySheet.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/SelectGroupCurrencySheet.kt index 2808b7b7aa..e0b295806b 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/SelectGroupCurrencySheet.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/SelectGroupCurrencySheet.kt @@ -101,7 +101,7 @@ internal fun SelectGroupCurrencySheet(viewModel: CreateGroupViewModel) { header = { Column { AllCurrenciesCard( - modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x3), selected = state.currency == GroupCurrency.All, total = state.totalBalance, amount = state.amount, From 1947fb87a2afe285f3bc3a05a8a891442000d435 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 24 Sep 2026 19:29:11 -0400 Subject: [PATCH 3/4] fix(tipping): animate the group sheets closed and keep the requirement heading fixed SelectGroupCurrencySheet and NewGroupCustomAmountSheet popped their flow entry directly, which deletes the scene mid-frame, so the sheet vanished instead of sliding down. Both now exit through LocalBottomSheetDismissDispatcher, as the chat flow's sheets do. The form heading and the custom amount sheet's title now always read "Minimum Balance Required". The switch to "Balance Requirement" is gone, along with the requirementStarted flag and the string. --- apps/flipcash/core/src/main/res/values/strings.xml | 1 - .../app/tipping/internal/CreateGroupViewModel.kt | 10 ---------- .../internal/screens/NewGroupCustomAmountSheet.kt | 13 ++++++------- .../tipping/internal/screens/NewGroupFormScreen.kt | 11 +---------- .../internal/screens/SelectGroupCurrencySheet.kt | 13 ++++++------- .../app/tipping/internal/CreateGroupStateTest.kt | 9 --------- 6 files changed, 13 insertions(+), 44 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 47a29756be..c981d0eff9 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1041,7 +1041,6 @@ Minimum Balance Required - Balance Requirement People won\'t be able to join this chat if their balance is less than this amount Create