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