diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 929df5953a..c981d0eff9 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1041,13 +1041,23 @@ Minimum Balance Required - Balance Requirement People won\'t be able to join this chat if their balance is less than this amount Create 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..ea8429bbd1 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,28 @@ 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() + /** * 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 +159,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 +208,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 +339,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 +352,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 +438,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 +509,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 +536,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/NewGroupCustomAmountSheet.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/NewGroupCustomAmountSheet.kt index a6dfc36320..cd11bd8485 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/NewGroupCustomAmountSheet.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/NewGroupCustomAmountSheet.kt @@ -1,17 +1,15 @@ package com.flipcash.app.tipping.internal.screens -import android.os.Parcelable import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign -import com.flipcash.app.core.chat.NewGroupStep import com.flipcash.app.tipping.internal.CreateGroupViewModel import com.flipcash.features.tipping.R import com.flipcash.shared.amountentry.AmountEntryScreen -import com.getcode.navigation.flow.rememberFlowNavigator +import com.getcode.navigation.scenes.LocalBottomSheetDismissDispatcher import com.getcode.theme.CodeTheme import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle @@ -31,7 +29,8 @@ import kotlinx.coroutines.flow.onEach */ @Composable internal fun NewGroupCustomAmountSheet(viewModel: CreateGroupViewModel) { - val flowNavigator = rememberFlowNavigator() + // Exit through the sheet so it animates down rather than having its scene deleted mid-frame. + val dismissSheet = LocalBottomSheetDismissDispatcher.current AmountEntryScreen( controller = viewModel.amountDelegate, @@ -39,9 +38,9 @@ internal fun NewGroupCustomAmountSheet(viewModel: CreateGroupViewModel) { largeHeader = true, appBar = { AppBarWithTitle( - title = stringResource(R.string.title_balanceRequirement), + title = stringResource(R.string.title_minimumBalanceRequired), titleAlignment = Alignment.CenterHorizontally, - endContent = { AppBarDefaults.Close { flowNavigator.back() } }, + endContent = { AppBarDefaults.Close(onClick = dismissSheet) }, ) }, headerCaption = { @@ -57,7 +56,7 @@ internal fun NewGroupCustomAmountSheet(viewModel: CreateGroupViewModel) { LaunchedEffect(viewModel) { viewModel.eventFlow .filterIsInstance() - .onEach { flowNavigator.back() } + .onEach { dismissSheet() } .launchIn(this) } } 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..16a51c2477 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,7 @@ private fun IdentityCard( } /** - * The balance rule: which mint, 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 balance rule: which currency, and how much of it. */ @Composable private fun RequirementSection( @@ -314,13 +310,7 @@ private fun RequirementSection( ) { Text( modifier = Modifier.padding(horizontal = CodeTheme.dimens.grid.x3), - text = stringResource( - if (!state.mintChosen) { - R.string.title_minimumBalanceRequired - } else { - R.string.title_balanceRequirement - } - ), + text = stringResource(R.string.title_minimumBalanceRequired), style = LabelStyle, color = White50, ) @@ -398,30 +388,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 +454,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..ad22bbf4eb 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,44 +1,72 @@ 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 import com.flipcash.app.core.tokens.TokenPurpose 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.navigation.scenes.LocalBottomSheetDismissDispatcher +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) { - val flowNavigator = rememberFlowNavigator() + // Exit through the sheet so it animates down rather than having its scene deleted mid-frame. + val dismissSheet = LocalBottomSheetDismissDispatcher.current val state by viewModel.stateFlow.collectAsStateWithLifecycle() val tokenViewModel = hiltViewModel() @@ -49,15 +77,14 @@ internal fun SelectGroupCurrencySheet(viewModel: CreateGroupViewModel) { AppBarWithTitle( title = stringResource(R.string.title_selectCurrency), titleAlignment = Alignment.CenterHorizontally, - endContent = { AppBarDefaults.Close { flowNavigator.back() } }, + endContent = { AppBarDefaults.Close(onClick = dismissSheet) }, ) }, ) { padding -> TokenList( modifier = Modifier .fillMaxWidth() - .padding(padding) - .padding(horizontal = CodeTheme.dimens.inset), + .padding(padding), tokens = tokenState.tokens, styling = rememberTokenBalanceRowStyling( // Pill balances, dividers and a radio mark per row, which is @@ -66,12 +93,52 @@ 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( + start = CardMargin, + end = CardMargin, + top = CodeTheme.dimens.grid.x3, + ), + selected = state.currency == GroupCurrency.All, + total = state.totalBalance, + amount = state.amount, + satisfied = state.allCurrenciesSatisfied, + onClick = { + viewModel.dispatchEvent( + CreateGroupViewModel.Event.OnCurrencySelected(GroupCurrency.All) + ) + dismissSheet() + }, + ) + + // 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)) - flowNavigator.back() + viewModel.dispatchEvent( + CreateGroupViewModel.Event.OnCurrencySelected( + GroupCurrency.Specific(token.address) + ) + ) + dismissSheet() }, ) } @@ -84,3 +151,112 @@ 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.inset - CardMargin, + 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 + +// The token rows pad their contents by `inset`. The card's margin and its own padding add up to the +// same, so its icon and check line up with the rows' icons and radios. +private val CardMargin: Dp + @Composable get() = CodeTheme.dimens.grid.x2 +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..956e9537b6 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,39 @@ 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 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 `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 `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 `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 @@ -110,11 +103,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 +148,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