From 190740c15919583142f35177da7dfca8ab3d1d9a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 17 Oct 2026 15:36:55 -0400 Subject: [PATCH 1/2] feat(chat): pick a new group's currency from a full-width row The New Public Group form now has a Community Currency section above Minimum Balance Required, holding a row styled like the group profile's Token row (32pt icon, 56pt tall, chevron). The balance card keeps only the presets, and the caption names the chosen token: "People need this amount of {Token} to join this chat." In the picker, the Total Balance card is gone and Any Community Currency is the last item. It still sends an empty mints list, so the server counts USDF toward it until the contract can exclude it. Layout follows Figma node 11745:1054. --- .../Screens/Main/SelectCurrencyScreen.swift | 51 +++------ .../Tips/New Group/AllCurrenciesCard.swift | 89 --------------- .../New Group/AnyCommunityCurrencyRow.swift | 102 ++++++++++++++++++ .../Tips/New Group/NewPublicGroupModel.swift | 29 ++--- .../Tips/New Group/NewPublicGroupScreen.swift | 97 +++++++---------- .../Chat/NewPublicGroupModelTests.swift | 52 +++++---- 6 files changed, 206 insertions(+), 214 deletions(-) delete mode 100644 Flipcash/Core/Screens/Main/Tips/New Group/AllCurrenciesCard.swift create mode 100644 Flipcash/Core/Screens/Main/Tips/New Group/AnyCommunityCurrencyRow.swift diff --git a/Flipcash/Core/Screens/Main/SelectCurrencyScreen.swift b/Flipcash/Core/Screens/Main/SelectCurrencyScreen.swift index a737012ad..1eca21727 100644 --- a/Flipcash/Core/Screens/Main/SelectCurrencyScreen.swift +++ b/Flipcash/Core/Screens/Main/SelectCurrencyScreen.swift @@ -9,7 +9,7 @@ import SwiftUI import FlipcashUI import FlipcashCore -struct SelectCurrencyScreen: View { +struct SelectCurrencyScreen: View { @Binding var isPresented: Bool @@ -19,20 +19,17 @@ struct SelectCurrencyScreen: View { let action: (ExchangedBalance) -> Void let isEnabled: (ExchangedBalance) -> Bool let isSelected: ((ExchangedBalance) -> Bool)? - private let header: Header? - private let listTitle: String? + private let footer: Footer? private var balances: [ExchangedBalance] { session.balances(for: ratesController.rateForBalanceCurrency()) .giveable() } - /// Creates a picker with `header` drawn above the currency list, and `listTitle` labelling the - /// list beneath it. + /// Creates a picker with `footer` drawn as the last item of the currency list. /// - /// The header spans the list's full width and sets its own margins. It stays on screen when - /// there is nothing giveable to list, since it is a choice of its own rather than one of the - /// list's rows. + /// The footer is a row of its own and sets its own margins. It stays on screen when there is + /// nothing giveable to list, since it is a choice of its own rather than one of the balances. /// /// - Parameter isSelected: which row draws its checkmark. Defaults to the currency the wallet is /// denominated in, which is what picking one here changes. Pass a closure when the screen is @@ -42,16 +39,14 @@ struct SelectCurrencyScreen: View { isPresented: Binding, isEnabled: @escaping (ExchangedBalance) -> Bool = { _ in true }, isSelected: ((ExchangedBalance) -> Bool)? = nil, - listTitle: String, action: @escaping (ExchangedBalance) -> Void, - @ViewBuilder header: () -> Header + @ViewBuilder footer: () -> Footer ) { self._isPresented = isPresented self.isEnabled = isEnabled self.isSelected = isSelected self.action = action - self.header = header() - self.listTitle = listTitle + self.footer = footer() } var body: some View { @@ -61,7 +56,7 @@ struct SelectCurrencyScreen: View { NavigationStack { Background(color: .backgroundMain) { - if balances.isEmpty && header == nil { + if balances.isEmpty && footer == nil { Text("No currencies to give") .font(.appTextMedium) .foregroundStyle(Color.textSecondary) @@ -71,24 +66,6 @@ struct SelectCurrencyScreen: View { .accessibilityIdentifier("give-picker-empty") } else { List { - if let header { - header - .listRowInsets(EdgeInsets()) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - - if let listTitle, !balances.isEmpty { - Text(listTitle) - .font(.default(size: 15, weight: .semibold)) - .foregroundStyle(Color.textMain.opacity(0.5)) - .padding(.top, 24) - .padding(.bottom, 4) - .listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20)) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - } - } - Section { ForEach(balances) { balance in let enabled = isEnabled(balance) @@ -108,6 +85,13 @@ struct SelectCurrencyScreen: View { } } .listRowInsets(EdgeInsets()) + + if let footer { + footer + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } } .listStyle(.plain) .scrollContentBackground(.hidden) @@ -124,7 +108,7 @@ struct SelectCurrencyScreen: View { } } -extension SelectCurrencyScreen where Header == EmptyView { +extension SelectCurrencyScreen where Footer == EmptyView { /// Creates a picker listing only the giveable currencies. /// @@ -140,8 +124,7 @@ extension SelectCurrencyScreen where Header == EmptyView { self.isEnabled = isEnabled self.isSelected = isSelected self.action = action - self.header = nil - self.listTitle = nil + self.footer = nil } } diff --git a/Flipcash/Core/Screens/Main/Tips/New Group/AllCurrenciesCard.swift b/Flipcash/Core/Screens/Main/Tips/New Group/AllCurrenciesCard.swift deleted file mode 100644 index a7f31ac3b..000000000 --- a/Flipcash/Core/Screens/Main/Tips/New Group/AllCurrenciesCard.swift +++ /dev/null @@ -1,89 +0,0 @@ -// -// AllCurrenciesCard.swift -// Flipcash -// - -import SwiftUI -import FlipcashCore -import FlipcashUI - -/// The currency picker's "Total Balance" choice for a group's balance requirement, stating the -/// user's total against the amount picked (node 10370:997). -struct AllCurrenciesCard: View { - - let isSelected: Bool - /// Every holding added together, in USD — the figure the requirement is weighed against. - let total: FiatAmount - /// The amount picked on the form, or nil while none is. - let requirement: FiatAmount? - let meetsRequirement: Bool - let action: () -> Void - - var body: some View { - Button(action: action) { - HStack(spacing: 12) { - AllCurrenciesIcon(size: 32) - - VStack(alignment: .leading, spacing: 2) { - Text("Total Balance") - .font(.appBarButton) - .foregroundStyle(Color.textMain) - - Text(subtitle) - .font(.default(size: 13, weight: .medium)) - .foregroundStyle(meetsRequirement ? Color.textMain.opacity(0.5) : Color.textError) - .lineLimit(1) - .minimumScaleFactor(0.8) - } - .frame(maxWidth: .infinity, alignment: .leading) - - RadioButton(isSelected: isSelected) - .accessibilityHidden(true) - } - .padding(.horizontal, 12) - .padding(.vertical, 16) - .background(Color.backgroundRow, in: .rect(cornerRadius: 12)) - .overlay { - RoundedRectangle(cornerRadius: 12) - .strokeBorder(Color.white.opacity(isSelected ? 0.5 : 0.15)) - } - .contentShape(.rect(cornerRadius: 12)) - } - // Margin plus padding matches the currency rows' 20pt inset, so the icon and check sit on - // the same lines as the rows' icons and checks. - .padding(.horizontal, 8) - .buttonStyle(.plain) - .accessibilityAddTraits(isSelected ? .isSelected : []) - .accessibilityIdentifier("all-currencies-card") - } - - private var subtitle: String { - // Rounded before it is shown, because the gate compares the rounded figure. - let held = "You have \(total.roundedToSmallestUnit().formatted()) total" - guard let requirement else { return held } - let verdict = meetsRequirement ? "meets" : "need" - return "\(held) · \(verdict) \(requirement.formattedDroppingZeroFraction())" - } -} - -/// The coins glyph on a grey disc that stands in for a token's icon when a requirement counts every -/// holding (node 10369:999). -struct AllCurrenciesIcon: View { - - let size: CGFloat - - var body: some View { - Circle() - // White at 15% over the sheet's charcoal is the design's #3B3B3D. - .fill(Color.white.opacity(0.15)) - .frame(width: size, height: size) - .overlay { - Image.asset(.coins) - .renderingMode(.template) - .resizable() - .scaledToFit() - .frame(width: size * 0.625, height: size * 0.625) - .foregroundStyle(Color.textMain) - } - } -} diff --git a/Flipcash/Core/Screens/Main/Tips/New Group/AnyCommunityCurrencyRow.swift b/Flipcash/Core/Screens/Main/Tips/New Group/AnyCommunityCurrencyRow.swift new file mode 100644 index 000000000..9c5b8d70e --- /dev/null +++ b/Flipcash/Core/Screens/Main/Tips/New Group/AnyCommunityCurrencyRow.swift @@ -0,0 +1,102 @@ +// +// AnyCommunityCurrencyRow.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// What a group's balance requirement is named when it counts any community currency rather than +/// one token. +enum AnyCommunityCurrency { + static let name = "Any Community Currency" +} + +/// The currency picker's "Any Community Currency" entry, the last item of its list. Laid out like +/// ``CurrencyBalanceRow`` so its icon and check sit on the same lines as the token rows'. +struct AnyCommunityCurrencyRow: View { + + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 8) { + AllCurrenciesIcon(size: 24) + + Text(AnyCommunityCurrency.name) + .font(.appBarButton) + .foregroundStyle(Color.textMain) + + Spacer() + + RadioButton(isSelected: isSelected) + .accessibilityHidden(true) + .padding(.leading, 12) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .listRowBackground(Color.clear) + .padding(.horizontal, 20) + .padding(.vertical, 20) + .accessibilityAddTraits(isSelected ? .isSelected : []) + .accessibilityIdentifier("any-community-currency-row") + } +} + +/// The form's tappable currency row: the chosen token, or the any-currency glyph, and a chevron. +struct CommunityCurrencyRow: View { + + let name: String + let action: () -> Void + @ViewBuilder let icon: () -> Icon + + var body: some View { + Button(action: action) { + HStack { + HStack(spacing: 8) { + icon() + Text(name) + .font(.appTextMedium) + .foregroundStyle(Color.textMain) + .lineLimit(1) + .truncationMode(.tail) + } + Spacer() + Image(systemName: "chevron.right") + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + } + .padding(.horizontal, 16) + .frame(height: 56) + .frame(maxWidth: .infinity) + .background(Color.backgroundRow, in: RoundedRectangle(cornerRadius: Metrics.boxRadius, style: .continuous)) + .contentShape(RoundedRectangle(cornerRadius: Metrics.boxRadius, style: .continuous)) + } + .buttonStyle(.plain) + } +} + +/// The coins glyph on a grey disc that stands in for a token's icon when a requirement counts any +/// community currency (node 10369:999). +struct AllCurrenciesIcon: View { + + let size: CGFloat + + var body: some View { + Circle() + // White at 15% over the sheet's charcoal is the design's #3B3B3D. + .fill(Color.white.opacity(0.15)) + .frame(width: size, height: size) + .overlay { + Image.asset(.coins) + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: size * 0.625, height: size * 0.625) + .foregroundStyle(Color.textMain) + } + } +} diff --git a/Flipcash/Core/Screens/Main/Tips/New Group/NewPublicGroupModel.swift b/Flipcash/Core/Screens/Main/Tips/New Group/NewPublicGroupModel.swift index 158ec36bf..bd381b6f8 100644 --- a/Flipcash/Core/Screens/Main/Tips/New Group/NewPublicGroupModel.swift +++ b/Flipcash/Core/Screens/Main/Tips/New Group/NewPublicGroupModel.swift @@ -32,9 +32,9 @@ final class NewPublicGroupModel { /// leaves the blob unset for a group with no picture. private(set) var picture: UIImage? - /// Which holdings count toward the requirement. Opens on ``CurrencyChoice/all``, the rule every + /// Which holdings count toward the requirement. Opens on ``CurrencyChoice/anyCommunityCurrency``, the rule every /// creator can draw without first having to pick a token. - private(set) var currency: CurrencyChoice = .all + private(set) var currency: CurrencyChoice = .anyCommunityCurrency /// The minimum balance a member must hold, in USD. private(set) var minimumBalance: FiatAmount? @@ -87,8 +87,8 @@ final class NewPublicGroupModel { currency = .specific(balance) } - func selectAllCurrencies() { - currency = .all + func selectAnyCommunityCurrency() { + currency = .anyCommunityCurrency } func select(minimumBalance: FiatAmount) { @@ -125,12 +125,14 @@ final class NewPublicGroupModel { return Self.rules(minimumBalance: minimumBalance, currency: currency) } - /// Whether the user's holdings added together clear the amount picked, whatever - /// ``currency`` is — what the picker's Total Balance card states. True while no amount is - /// picked. - func satisfiesAllCurrencies(session: some ConversationGateReading, rates: [CurrencyCode: Rate]) -> Bool { - let rules = minimumBalance.map { Self.rules(minimumBalance: $0, currency: .all) } - return conversationGate(session: session, rules: rules, rates: rates).listener.isSatisfied + /// What sits under the presets: who can join, and with how much of which currency. + var requirementCaption: String { + let currencyName: String + switch currency { + case .anyCommunityCurrency: currencyName = "any community currency" + case .specific(let balance): currencyName = balance.stored.name + } + return "People need this amount of \(currencyName) to join this chat." } /// The contract caps the mint list at one entry, and an empty list is the requirement applying @@ -306,8 +308,9 @@ extension NewPublicGroupModel { /// The holdings a group's balance requirement counts. enum CurrencyChoice: Equatable { - /// Every holding added together. - case all + /// Every holding added together, sent as an empty mint list. The contract can't say + /// "community currencies only", so USDF counts too. + case anyCommunityCurrency /// One holding, kept whole so the form's row can draw the token's name and icon. Only /// ``ExchangedBalance/stored``'s mint reaches the wire. case specific(ExchangedBalance) @@ -315,7 +318,7 @@ extension NewPublicGroupModel { /// The mint of the chosen holding, or nil when every holding counts. var mint: PublicKey? { switch self { - case .all: return nil + case .anyCommunityCurrency: return nil case .specific(let balance): return balance.stored.mint } } diff --git a/Flipcash/Core/Screens/Main/Tips/New Group/NewPublicGroupScreen.swift b/Flipcash/Core/Screens/Main/Tips/New Group/NewPublicGroupScreen.swift index 777798624..e95bdac62 100644 --- a/Flipcash/Core/Screens/Main/Tips/New Group/NewPublicGroupScreen.swift +++ b/Flipcash/Core/Screens/Main/Tips/New Group/NewPublicGroupScreen.swift @@ -53,6 +53,8 @@ struct NewPublicGroupScreen: View { switch model.privacy { case .public: + currencySection + .padding(.top, 37) requirementSection .padding(.top, 37) case .private: @@ -99,21 +101,12 @@ struct NewPublicGroupScreen: View { isPresented: $isPickingCurrency, // The check follows the requirement's mint, not the wallet's denomination — this // screen is picking what the gate weighs, not what the balance is shown in. - isSelected: { $0.stored.mint == model.currency.mint }, - listTitle: "Specific Currency" + isSelected: { $0.stored.mint == model.currency.mint } ) { balance in model.select(balance: balance) - } header: { - AllCurrenciesCard( - isSelected: model.currency == .all, - total: session.totalBalance.usdfValue, - requirement: model.minimumBalance, - meetsRequirement: model.satisfiesAllCurrencies( - session: session, - rates: ratesController.cachedRates - ) - ) { - model.selectAllCurrencies() + } footer: { + AnyCommunityCurrencyRow(isSelected: model.currency == .anyCommunityCurrency) { + model.selectAnyCommunityCurrency() isPickingCurrency = false } } @@ -161,26 +154,43 @@ struct NewPublicGroupScreen: View { .background(Color.backgroundRow, in: .rect(cornerRadius: Metrics.buttonRadius)) } - private var requirementSection: some View { + /// The holdings the requirement counts, opening the currency picker (node 11745:1054). + private var currencySection: some View { VStack(alignment: .leading, spacing: 10) { - Text("Minimum Balance Required") - .font(.default(size: 17, weight: .bold)) - .foregroundStyle(Color.textMain.opacity(0.5)) - .padding(.horizontal, 12) + sectionHeader("Community Currency") - VStack(alignment: .leading, spacing: 10) { - mintRow - presets + Group { + switch model.currency { + case .anyCommunityCurrency: + CommunityCurrencyRow(name: AnyCommunityCurrency.name, action: { isPickingCurrency = true }) { + AllCurrenciesIcon(size: 32) + } + case .specific(let balance): + CommunityCurrencyRow(name: balance.stored.name, action: { isPickingCurrency = true }) { + TokenIcon(url: balance.stored.imageURL, monogramID: balance.stored.mint.base58, size: 32) + } + } } - .padding(12) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.backgroundRow, in: .rect(cornerRadius: Metrics.buttonRadius)) + .disabled(model.isCreating) + .accessibilityIdentifier("new-group-currency-row") + } + } - Text("People won't be able to join this chat if their balance is less than this amount") + private var requirementSection: some View { + VStack(alignment: .leading, spacing: 10) { + sectionHeader("Minimum Balance Required") + + presets + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.backgroundRow, in: .rect(cornerRadius: Metrics.buttonRadius)) + + Text(model.requirementCaption) .font(.default(size: 13, weight: .medium)) .foregroundStyle(Color.textMain.opacity(0.5)) .fixedSize(horizontal: false, vertical: true) .padding(.horizontal, 12) + .accessibilityIdentifier("new-group-requirement-caption") // The server refuses a chat whose creator is short of its own rules, so say so here // rather than spending the round trip to be told. @@ -195,38 +205,11 @@ struct NewPublicGroupScreen: View { } } - /// The holdings the requirement counts, opening the currency picker (nodes 10127:118254 and - /// 10364:1059). - private var mintRow: some View { - Button { isPickingCurrency = true } label: { - HStack(spacing: 4) { - switch model.currency { - case .all: - AllCurrenciesIcon(size: 20) - - Text("Total Balance") - .font(.appTextMedium) - .foregroundStyle(Color.textMain) - - case .specific(let balance): - RemoteImage(url: balance.stored.imageURL) - .frame(width: 20, height: 20) - .clipShape(Circle()) - - Text(balance.stored.name) - .font(.appTextMedium) - .foregroundStyle(Color.textMain) - } - - Image.system(.chevronDown) - .font(.appTextSmall) - .foregroundStyle(Color.textSecondary) - } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .disabled(model.isCreating) - .accessibilityIdentifier("new-group-currency-row") + private func sectionHeader(_ title: String) -> some View { + Text(title) + .font(.default(size: 17, weight: .bold)) + .foregroundStyle(Color.textMain.opacity(0.5)) + .padding(.horizontal, 12) } private var presets: some View { diff --git a/FlipcashTests/Chat/NewPublicGroupModelTests.swift b/FlipcashTests/Chat/NewPublicGroupModelTests.swift index 68af0b0d1..d6cc74499 100644 --- a/FlipcashTests/Chat/NewPublicGroupModelTests.swift +++ b/FlipcashTests/Chat/NewPublicGroupModelTests.swift @@ -205,38 +205,56 @@ struct NewPublicGroupModelTests { // MARK: - Currency choice - - @Test("The form opens on Total Balance") + @Test("The form opens on Any Community Currency") func opensOnAllCurrencies() { let model = NewPublicGroupModel() - #expect(model.currency == .all) + #expect(model.currency == .anyCommunityCurrency) #expect(model.currency.mint == nil, "No token row reads as selected in the picker") } - @Test("Picking a token replaces Total Balance") + @Test("Picking a token replaces Any Community Currency") func pickingATokenReplacesAll() { let model = NewPublicGroupModel() model.select(balance: .makeTest(mint: .jeffy)) - #expect(model.currency != .all) + #expect(model.currency != .anyCommunityCurrency) #expect(model.currency.mint == .jeffy) } - @Test("Choosing Total Balance after a token clears the token") + @Test("Choosing Any Community Currency after a token clears the token") func choosingAllClearsTheToken() { let model = NewPublicGroupModel() model.select(balance: .makeTest(mint: .jeffy)) - model.selectAllCurrencies() + model.selectAnyCommunityCurrency() - #expect(model.currency == .all) + #expect(model.currency == .anyCommunityCurrency) #expect(model.currency.mint == nil) } + @Test("The caption names the picked token") + func captionNamesTheToken() { + let model = filledModel() + + guard case .specific(let balance) = model.currency else { + Issue.record("filledModel picks a token") + return + } + #expect(model.requirementCaption == "People need this amount of \(balance.stored.name) to join this chat.") + } + + @Test("The caption says any community currency while that is picked") + func captionForAnyCommunityCurrency() { + let model = NewPublicGroupModel() + + #expect(model.requirementCaption == "People need this amount of any community currency to join this chat.") + } + // MARK: - Rules - - @Test("Total Balance maps to a requirement with no mints") + @Test("Any Community Currency maps to a requirement with no mints") func allCurrenciesRulesCarryNoMints() { let model = NewPublicGroupModel() model.select(minimumBalance: .usd(100)) @@ -259,11 +277,11 @@ struct NewPublicGroupModelTests { #expect(model.rules?.speaker.isEmpty == true) } - @Test("Switching back to Total Balance drops the mint from the rules") + @Test("Switching back to Any Community Currency drops the mint from the rules") func switchingBackToAllDropsTheMint() { let model = filledModel() - model.selectAllCurrencies() + model.selectAnyCommunityCurrency() #expect(model.rules == ConversationRules( listener: [.minimumBalance(MinimumBalanceRequirement(amount: .usd(100), mints: []))] @@ -280,7 +298,7 @@ struct NewPublicGroupModelTests { #expect(model.rules != nil) } - @Test("Create sends a Total Balance group with no mints") + @Test("Create sends a Any Community Currency group with no mints") func createSendsEmptyMints() async throws { let model = NewPublicGroupModel() model.title = "Ballers" @@ -319,7 +337,7 @@ struct NewPublicGroupModelTests { #expect(model.canCreate(session: StubHoldings(usd: 10_000), rates: noRates) == false) } - @Test("Total Balance counts every holding toward the creator's own requirement") + @Test("Any Community Currency counts every holding toward the creator's own requirement") func allCurrenciesSumsTheCreatorsHoldings() { let model = NewPublicGroupModel() model.title = "Ballers" @@ -332,7 +350,7 @@ struct NewPublicGroupModelTests { #expect(model.canCreate(session: spread, rates: noRates)) } - @Test("Total Balance stays short when the holdings added together are short") + @Test("Any Community Currency stays short when the holdings added together are short") func allCurrenciesShortWhenTheTotalIsShort() { let model = NewPublicGroupModel() model.title = "Ballers" @@ -352,14 +370,6 @@ struct NewPublicGroupModelTests { #expect(model.satisfiesOwnRules(session: spread, rates: noRates) == false) } - @Test("The Total Balance card weighs the total even while a token is picked") - func allCurrenciesCardWeighsTheTotal() { - let model = filledModel(minimum: 100) - - #expect(model.satisfiesAllCurrencies(session: StubHoldings(usd: 40, otherUSD: 60), rates: noRates)) - #expect(model.satisfiesAllCurrencies(session: StubHoldings(usd: 40, otherUSD: 59), rates: noRates) == false) - } - @Test("An incomplete requirement keeps Create disabled") func createDisabledWithoutARequirement() { let model = NewPublicGroupModel() From 199384bbdf4c26cb6679a591ea4f433a6829903b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 17 Oct 2026 15:45:32 -0400 Subject: [PATCH 2/2] feat(chat): hide USDF from the new group currency picker Any Community Currency is the last row of the picker, and dollars aren't a community currency. SelectCurrencyScreen takes excludesUSDF (default false), which only the new group screen sets. --- Flipcash/Core/Screens/Main/ExchangedBalance.swift | 6 ++++++ .../Core/Screens/Main/SelectCurrencyScreen.swift | 9 ++++++++- .../Main/Tips/New Group/NewPublicGroupScreen.swift | 2 ++ FlipcashTests/Chat/NewPublicGroupModelTests.swift | 14 ++++++++++++++ 4 files changed, 30 insertions(+), 1 deletion(-) diff --git a/Flipcash/Core/Screens/Main/ExchangedBalance.swift b/Flipcash/Core/Screens/Main/ExchangedBalance.swift index f4e75ad37..f9c55598d 100644 --- a/Flipcash/Core/Screens/Main/ExchangedBalance.swift +++ b/Flipcash/Core/Screens/Main/ExchangedBalance.swift @@ -26,6 +26,12 @@ extension Array where Element == ExchangedBalance { fundable() } + /// These balances without Dollars, for a picker that names a community currency: dollars + /// is the stable token, not one. + func excludingUSDF() -> [ExchangedBalance] { + filter { $0.stored.mint != .usdf } + } + /// Balances eligible to withdraw. func withdrawable() -> [ExchangedBalance] { fundable() diff --git a/Flipcash/Core/Screens/Main/SelectCurrencyScreen.swift b/Flipcash/Core/Screens/Main/SelectCurrencyScreen.swift index 1eca21727..65f40bc9b 100644 --- a/Flipcash/Core/Screens/Main/SelectCurrencyScreen.swift +++ b/Flipcash/Core/Screens/Main/SelectCurrencyScreen.swift @@ -19,11 +19,13 @@ struct SelectCurrencyScreen: View { let action: (ExchangedBalance) -> Void let isEnabled: (ExchangedBalance) -> Bool let isSelected: ((ExchangedBalance) -> Bool)? + let excludesUSDF: Bool private let footer: Footer? private var balances: [ExchangedBalance] { - session.balances(for: ratesController.rateForBalanceCurrency()) + let giveable = session.balances(for: ratesController.rateForBalanceCurrency()) .giveable() + return excludesUSDF ? giveable.excludingUSDF() : giveable } /// Creates a picker with `footer` drawn as the last item of the currency list. @@ -35,14 +37,18 @@ struct SelectCurrencyScreen: View { /// denominated in, which is what picking one here changes. Pass a closure when the screen is /// picking for something else — a group chat's balance requirement, say — so the check follows /// that choice instead of the wallet's. + /// - Parameter excludesUSDF: drops Dollars from the list, for a screen that picks a community + /// currency. Defaults to showing every giveable balance. init( isPresented: Binding, + excludesUSDF: Bool = false, isEnabled: @escaping (ExchangedBalance) -> Bool = { _ in true }, isSelected: ((ExchangedBalance) -> Bool)? = nil, action: @escaping (ExchangedBalance) -> Void, @ViewBuilder footer: () -> Footer ) { self._isPresented = isPresented + self.excludesUSDF = excludesUSDF self.isEnabled = isEnabled self.isSelected = isSelected self.action = action @@ -121,6 +127,7 @@ extension SelectCurrencyScreen where Footer == EmptyView { action: @escaping (ExchangedBalance) -> Void ) { self._isPresented = isPresented + self.excludesUSDF = false self.isEnabled = isEnabled self.isSelected = isSelected self.action = action diff --git a/Flipcash/Core/Screens/Main/Tips/New Group/NewPublicGroupScreen.swift b/Flipcash/Core/Screens/Main/Tips/New Group/NewPublicGroupScreen.swift index e95bdac62..604d2f18f 100644 --- a/Flipcash/Core/Screens/Main/Tips/New Group/NewPublicGroupScreen.swift +++ b/Flipcash/Core/Screens/Main/Tips/New Group/NewPublicGroupScreen.swift @@ -99,6 +99,8 @@ struct NewPublicGroupScreen: View { .sheet(isPresented: $isPickingCurrency) { SelectCurrencyScreen( isPresented: $isPickingCurrency, + // Dollars isn't a community currency; "Any Community Currency" is the last row. + excludesUSDF: true, // The check follows the requirement's mint, not the wallet's denomination — this // screen is picking what the gate weighs, not what the balance is shown in. isSelected: { $0.stored.mint == model.currency.mint } diff --git a/FlipcashTests/Chat/NewPublicGroupModelTests.swift b/FlipcashTests/Chat/NewPublicGroupModelTests.swift index d6cc74499..28e13d2e6 100644 --- a/FlipcashTests/Chat/NewPublicGroupModelTests.swift +++ b/FlipcashTests/Chat/NewPublicGroupModelTests.swift @@ -312,6 +312,20 @@ struct NewPublicGroupModelTests { )) } + // MARK: - The currency picker - + + @Test("The picker's list drops Dollars and keeps every other balance") + func excludingUSDFDropsOnlyDollars() { + let balances: [ExchangedBalance] = [ + .makeTest(mint: .usdf, quarks: 1_000_000), + .makeTest(mint: .jeffy), + ] + + let listed = balances.excludingUSDF() + + #expect(listed.map(\.stored.mint) == [.jeffy]) + } + // MARK: - The creator's own balance - @Test("Create stays disabled while the creator is short of the bar they drew")