diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 1601f1cf55..f908e3268b 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1235,21 +1235,19 @@ Minimum Balance Required People won\'t be able to join this chat if their balance is less than this amount + + People need this amount of %1$s to join this chat. + People need this amount of any community currency to join this chat. Create You Need More %1$s You have to meet the balance requirement you set to create this group - + You Need a Higher Balance - - Total Balance - - You have %1$s total · meets %2$s - - You have %1$s total · need %2$s - - You have %1$s total + + Community Currency + Any Community Currency Specific Currency This Group Name is Not Allowed These Rules Aren\'t Allowed 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 c33cf2c271..7dc4dabc46 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 @@ -37,7 +37,6 @@ 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.opencode.model.financial.sum import com.getcode.util.resources.ContentReader import com.getcode.util.resources.ResourceHelper import com.getcode.view.BaseViewModel @@ -152,13 +151,6 @@ internal class CreateGroupViewModel @AssistedInject constructor( 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. * @@ -177,13 +169,6 @@ internal class CreateGroupViewModel @AssistedInject constructor( val access: GroupAccess? 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, 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 index 596ea29171..07dc57f882 100644 --- 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 @@ -3,8 +3,8 @@ 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). + * What a group's balance requirement is measured in — the currency sheet's "Any Community + * Currency" entry or one of the tokens above it. * * [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 @@ -23,3 +23,11 @@ internal sealed interface GroupCurrency { override val mints: List = listOf(mint) } } + +/** + * The rows the currency sheet offers as specific currencies: everything but USDF. Dollars aren't a + * community currency, and "Any Community Currency" already covers them in the USD total. The sheet + * is the only thing that sets [GroupCurrency.Specific], so a draft can't hold USDF. + */ +internal fun List.withoutUsdf(mintOf: (T) -> Mint): List = + filterNot { mintOf(it) == Mint.usdf } 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 f7c25b072a..6e29f67a75 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 @@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.imePadding 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.foundation.text.KeyboardOptions import androidx.compose.material3.Icon import androidx.compose.material3.Text @@ -35,6 +36,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -311,7 +313,7 @@ private fun IdentityCard( } /** - * The balance rule: which currency, and how much of it. + * The balance rule: which currency (its own section, over the amount), and how much of it. */ @Composable private fun RequirementSection( @@ -326,6 +328,20 @@ private fun RequirementSection( ) { Text( modifier = Modifier.padding(horizontal = CodeTheme.dimens.grid.x3), + text = stringResource(R.string.title_communityCurrency), + style = LabelStyle, + color = White50, + ) + + MintRow(state = state, onClick = onChangeCurrency) + + Text( + // Its own section: the extra top inset is the gap between sections, not a label's. + modifier = Modifier.padding( + start = CodeTheme.dimens.grid.x3, + end = CodeTheme.dimens.grid.x3, + top = CodeTheme.dimens.grid.x3, + ), text = stringResource(R.string.title_minimumBalanceRequired), style = LabelStyle, color = White50, @@ -339,8 +355,6 @@ private fun RequirementSection( .padding(CodeTheme.dimens.grid.x3), verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x2), ) { - MintRow(state = state, onClick = onChangeCurrency) - Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), @@ -384,29 +398,40 @@ private fun RequirementSection( Text( modifier = Modifier.padding(horizontal = CodeTheme.dimens.grid.x3), - text = stringResource( - if (unsatisfied) { - R.string.error_description_groupRuleNotSelfSatisfied - } else { - R.string.subtitle_groupBalanceRequirement - } - ), + text = when { + unsatisfied -> stringResource(R.string.error_description_groupRuleNotSelfSatisfied) + state.currency == GroupCurrency.All -> + stringResource(R.string.subtitle_groupBalanceRequirementAny) + // A picked token that has left the list has no name; the rule still holds for it. + else -> state.currencyName?.let { + stringResource(R.string.subtitle_groupBalanceRequirementToken, it) + } ?: stringResource(R.string.subtitle_groupBalanceRequirement) + }, style = CodeTheme.typography.caption.copy(fontSize = 13.sp, lineHeight = 17.sp), color = if (unsatisfied) CodeTheme.colors.errorText else White50, ) } } +/** + * The currency row, dressed as the group info screen's Token row: 56 high, 16 in from the edges, + * a 32 icon and the name, and a chevron in textSecondary on the 12-radius card background. + */ @Composable private fun MintRow(state: CreateGroupViewModel.State, onClick: () -> Unit) { Row( - modifier = Modifier.clickable(onClick = onClick), - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(MintRowRadius)) + .background(White05) + .height(MintRowHeight) + .clickable(onClick = onClick) + .padding(horizontal = MintRowInset), + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x2), verticalAlignment = Alignment.CenterVertically, ) { - val allCurrencies = state.currency == GroupCurrency.All - if (allCurrencies) { - // Node 10364:1059 — the sheet's card glyph, scaled to the row. + val anyCurrency = state.currency == GroupCurrency.All + if (anyCurrency) { AllCurrenciesIcon(discSize = MintIconSize, iconSize = AllCurrenciesGlyphSize) } else { AsyncImage( @@ -424,25 +449,24 @@ private fun MintRow(state: CreateGroupViewModel.State, onClick: () -> Unit) { // 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_totalBalance) + val name = if (anyCurrency) { + stringResource(R.string.title_anyCommunityCurrency) } else { state.currencyName } Text( + modifier = Modifier.weight(1f), text = name ?: stringResource(R.string.title_selectCurrency), - style = CodeTheme.typography.textMedium.copy( - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - ), - color = if (name == null) White50 else CodeTheme.colors.textMain, + style = CodeTheme.typography.textMedium, + color = if (name == null) CodeTheme.colors.textSecondary else CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) Icon( - painter = painterResource(R.drawable.ic_chevron_down_small), + painter = painterResource(R.drawable.ic_chevron_right), contentDescription = null, - tint = CodeTheme.colors.textMain, - modifier = Modifier.size(CodeTheme.dimens.staticGrid.x5), + tint = CodeTheme.colors.textSecondary, ) } } @@ -469,8 +493,11 @@ 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 MintIconSize = 32.dp +private val AllCurrenciesGlyphSize = 20.dp +private val MintRowHeight = 56.dp +private val MintRowInset = 16.dp +private val MintRowRadius = 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 6fe6a57ef5..c9aae47f9f 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 @@ -2,17 +2,21 @@ package com.flipcash.app.tipping.internal.screens import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.border 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.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -24,36 +28,34 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role -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.tokens.TokenPurpose +import com.flipcash.app.core.ui.TokenBalanceRowStyling 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.tipping.internal.withoutUsdf 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.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, with the - * Total Balance card over the token list (node 10370:997). + * Node 10127:118100 — which currency the group's balance requirement is denominated in: the token + * list with an "Any Community Currency" entry after the last token. * * 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 @@ -61,8 +63,9 @@ import com.getcode.ui.theme.CodeScaffold * 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. + * The entry and the tokens are one selection: picking either replaces the other, and while All is + * chosen no token is marked. USDF is left out of the list: dollars aren't a community currency, and + * the All entry already counts them. */ @Composable internal fun SelectGroupCurrencySheet(viewModel: CreateGroupViewModel) { @@ -73,6 +76,14 @@ internal fun SelectGroupCurrencySheet(viewModel: CreateGroupViewModel) { val tokenViewModel = hiltViewModel() val tokenState by tokenViewModel.stateFlow.collectAsStateWithLifecycle() + val rowStyling = rememberTokenBalanceRowStyling( + // Pill balances, dividers and a radio mark per row, which is + // TokenListPresentation.Default's treatment — the compact Sheet presentation drops + // all three, and this sheet shows all three. + balanceDisplayStyle = TokenBalanceStyle.Pill(), + selectionStyle = TokenSelectionStyle.Checkbox, + ) + CodeScaffold( topBar = { AppBarWithTitle( @@ -86,52 +97,22 @@ internal fun SelectGroupCurrencySheet(viewModel: CreateGroupViewModel) { modifier = Modifier .fillMaxWidth() .padding(padding), - tokens = tokenState.tokens, - styling = rememberTokenBalanceRowStyling( - // Pill balances, dividers and a radio mark per row, which is - // TokenListPresentation.Default's treatment — the compact Sheet presentation drops - // all three, and this sheet shows all three. - balanceDisplayStyle = TokenBalanceStyle.Pill(), - selectionStyle = TokenSelectionStyle.Checkbox, - ), + tokens = tokenState.tokens?.withoutUsdf { it.token.address }, + styling = rowStyling, 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, + footer = { + AnyCurrencyRow( + selected = state.currency == GroupCurrency.All, + styling = rowStyling, + onClick = { + viewModel.dispatchEvent( + CreateGroupViewModel.Event.OnCurrencySelected(GroupCurrency.All) ) - } - } + dismissSheet() + }, + ) }, onTokenSelected = { token -> viewModel.dispatchEvent( @@ -154,78 +135,62 @@ internal fun SelectGroupCurrencySheet(viewModel: CreateGroupViewModel) { } /** - * The Total Balance 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. + * The "Any Community Currency" entry, last in the list. It is the same radio selection as the token + * rows above it, so it takes its metrics from the same [TokenBalanceRowStyling] [TokenList] hands + * them (name style, icon size, vertical padding, check mark) and keeps the divider the rows have. + * The navigation-bar inset sits below it so the last row clears the gesture bar when scrolled to + * the end. */ @Composable -private fun AllCurrenciesCard( +private fun AnyCurrencyRow( selected: Boolean, - total: Fiat, - amount: Fiat?, - satisfied: Boolean?, + styling: TokenBalanceRowStyling, 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, - ) - .selectable(selected = selected, role = Role.RadioButton, 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.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .selectable(selected = selected, role = Role.RadioButton, onClick = onClick) + .padding(horizontal = CodeTheme.dimens.inset) + .padding(styling.contentPadding), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + verticalAlignment = Alignment.CenterVertically, + ) { + AllCurrenciesIcon( + discSize = styling.iconSize, + iconSize = styling.iconSize * (AllCurrenciesIconSize / AllCurrenciesDiscSize), + ) - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringResource(R.string.title_totalBalance), - style = CodeTheme.typography.textMedium.copy(fontWeight = FontWeight.SemiBold), - color = CodeTheme.colors.textMain, - ) + Text( + modifier = Modifier.weight(1f), + text = stringResource(R.string.title_anyCommunityCurrency), + style = styling.nameTextStyle, + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } - val totalText = total.formatted() - Text( - text = when { - amount == null -> stringResource(R.string.subtitle_totalBalance, totalText) - satisfied == false -> stringResource( - R.string.subtitle_totalBalanceNeeds, - totalText, - amount.formatted(rule = Fiat.FormattingRule.Truncated), - ) - else -> stringResource( - R.string.subtitle_totalBalanceMeets, - 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, + Image( + modifier = Modifier + .wrapContentWidth() + .padding(start = CodeTheme.dimens.grid.x3), + painter = painterResource( + if (selected) R.drawable.ic_checked else R.drawable.ic_unchecked + ), + contentDescription = null, ) } - // 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, - ) + HorizontalDivider(color = CodeTheme.colors.dividerVariant) + Spacer(Modifier.windowInsetsPadding(WindowInsets.navigationBars)) } } @@ -252,12 +217,5 @@ internal fun AllCurrenciesIcon( } } -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 a604881883..9651ce910a 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 @@ -157,10 +157,8 @@ class CreateGroupStateTest { 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 @@ -173,16 +171,12 @@ class CreateGroupStateTest { 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))) + fun `the currency list drops USDF and keeps every other token in order`() { + val tokens = listOf(mint(1), Mint.usdf, mint(2)) - assertFalse(state.selfSatisfied) - assertEquals(true, state.allCurrenciesSatisfied) - assertNull(state(amount = null).allCurrenciesSatisfied) + assertEquals(listOf(mint(1), mint(2)), tokens.withoutUsdf { it }) } }