diff --git a/Flipcash/Core/Screens/Main/Currency Convert/ConvertAmountViewModel.swift b/Flipcash/Core/Screens/Main/Currency Convert/ConvertAmountViewModel.swift index 1e2cb0623..f757cd122 100644 --- a/Flipcash/Core/Screens/Main/Currency Convert/ConvertAmountViewModel.swift +++ b/Flipcash/Core/Screens/Main/Currency Convert/ConvertAmountViewModel.swift @@ -46,9 +46,10 @@ final class ConvertAmountViewModel { /// Destinations offered in the picker: Dollars plus every other held /// balance, excluding the source. Read by the destination picker sheet. var destinationOptions: [ExchangedBalance] { - let rate = ratesController.rateForBalanceCurrency() - return session.balances(for: rate) - .filter { $0.stored.mint != sourceBalance.mint } + session.convertDestinations( + from: sourceBalance.mint, + rate: ratesController.rateForBalanceCurrency() + ) } /// Display name of the current destination — "Dollars" for USDF. @@ -88,8 +89,10 @@ final class ConvertAmountViewModel { // Default to Dollars, except when converting *from* Dollars — then pick // the largest other holding so the destination is never the source. if sourceBalance.mint == .usdf { - let rate = ratesController.rateForBalanceCurrency() - let options = session.balances(for: rate).filter { $0.stored.mint != .usdf } + let options = session.convertDestinations( + from: .usdf, + rate: ratesController.rateForBalanceCurrency() + ) self.destinationMint = options .max(by: { $0.exchangedFiat.nativeAmount.value < $1.exchangedFiat.nativeAmount.value })? .stored.mint ?? .usdf diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift index 7574439d6..01adb2d98 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift @@ -38,7 +38,7 @@ struct CurrencyInfoContentV2: View { /// Give — owned community tokens only. let onGive: () -> Void - /// Convert — sells this (non-USDF) currency into a chosen destination. + /// Convert — sells this currency into a chosen destination. let onConvert: () -> Void /// Buy — routes to the buy flow, whether or not the currency is held. let onBuy: () -> Void @@ -302,8 +302,14 @@ struct CurrencyInfoContentV2: View { if isUSDF { // Dollars is what the other currencies are bought with, so // there is nothing to buy more of — it withdraws instead. - // Its Convert goes out via a reserves buy. - actionTile("Convert", icon: .asset("IconArrowBottomTop"), action: onConvert) + // Its Convert goes out via a reserves buy, so it needs + // another holding to buy into. + actionTile( + "Convert", + icon: .asset("IconArrowBottomTop"), + isEnabled: viewModel.canConvert, + action: onConvert + ) actionTile("Withdraw", icon: .asset("IconArrowUp"), action: onWithdraw) } else { actionTile("Buy More", icon: .asset("IconArrowDown"), action: onBuy) @@ -315,7 +321,12 @@ struct CurrencyInfoContentV2: View { } } - private func actionTile(_ title: String, icon: TileIcon, action: @escaping () -> Void) -> some View { + private func actionTile( + _ title: String, + icon: TileIcon, + isEnabled: Bool = true, + action: @escaping () -> Void + ) -> some View { Button(action: action) { VStack(spacing: 12) { tileIcon(icon) @@ -330,6 +341,8 @@ struct CurrencyInfoContentV2: View { .clipShape(RoundedRectangle(cornerRadius: Metrics.buttonRadius, style: .continuous)) } .buttonStyle(.plain) + .disabled(!isEnabled) + .opacity(isEnabled ? 1 : 0.4) } @ViewBuilder private func tileIcon(_ icon: TileIcon) -> some View { diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoViewModel.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoViewModel.swift index 3e757abe4..71328bc26 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoViewModel.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoViewModel.swift @@ -84,6 +84,17 @@ class CurrencyInfoViewModel { ).nativeAmount } + /// Whether Convert has somewhere to go. Converting into Dollars is always + /// possible, so only the Dollars screen depends on other holdings — the + /// same list the convert destination picker shows. + var canConvert: Bool { + guard mint == .usdf else { return true } + return !session.convertDestinations( + from: .usdf, + rate: ratesController.rateForBalanceCurrency() + ).isEmpty + } + /// The absolute appreciation (or depreciation) of this currency's balance /// relative to its cost basis, converted to the display currency. /// Returns zero with `isPositive: true` when diff --git a/Flipcash/Core/Session/Session.swift b/Flipcash/Core/Session/Session.swift index 1b51c5831..f18b2f837 100644 --- a/Flipcash/Core/Session/Session.swift +++ b/Flipcash/Core/Session/Session.swift @@ -157,6 +157,12 @@ class Session { } } + /// The holdings a conversion out of `sourceMint` can go into: every balance + /// ``balances(for:)`` shows, minus the source itself. + func convertDestinations(from sourceMint: PublicKey, rate: Rate) -> [ExchangedBalance] { + balances(for: rate).filter { $0.stored.mint != sourceMint } + } + /// The balances the wallet shows a card for: every token it holds, and /// Dollars only once they are worth showing. static func walletCardBalances(from balances: [ExchangedBalance]) -> [ExchangedBalance] { diff --git a/FlipcashTests/Convert/ConvertDestinationAvailabilityTests.swift b/FlipcashTests/Convert/ConvertDestinationAvailabilityTests.swift new file mode 100644 index 000000000..a4f746710 --- /dev/null +++ b/FlipcashTests/Convert/ConvertDestinationAvailabilityTests.swift @@ -0,0 +1,101 @@ +// +// ConvertDestinationAvailabilityTests.swift +// FlipcashTests +// + +import Foundation +import Testing +@testable import FlipcashCore +import FlipcashStore +@testable import Flipcash + +@Suite("Convert availability on the currency info screen") +@MainActor +struct ConvertDestinationAvailabilityTests { + + private static let jeffySupply: UInt64 = 50_000 * 10_000_000_000 + private static let jeffyQuarks: UInt64 = 2_000 * 10_000_000_000 // ≈ $20 of curve value + + /// Reserves are seeded in every case so a disabled result comes from the + /// holdings, not from a curve that can't be priced. + private static func makeContainer(holdings: [SessionContainer.Holding]) async throws -> SessionContainer { + let container = try SessionContainer.makeTest(holdings: holdings) + + container.ratesController.configureTestRates( + balanceCurrency: .usd, + rates: [Rate(fx: 1, currency: .usd)] + ) + await container.ratesController.verifiedProtoService.saveRates([ + .freshRate(currencyCode: "USD", rate: 1) + ]) + await container.ratesController.verifiedProtoService.saveReserveStates([ + .freshReserve(mint: .jeffy, supplyFromBonding: jeffySupply) + ]) + + return container + } + + private static func canConvert(_ mint: PublicKey, in container: SessionContainer) -> Bool { + CurrencyInfoViewModel( + mint: mint, + session: container.session, + database: container.database, + ratesController: container.ratesController + ).canConvert + } + + private static let jeffy = MintMetadata.makeLaunchpad(address: .jeffy, supplyFromBonding: jeffySupply) + + @Test("Dollars only: Convert on the Dollars screen is disabled") + func dollarsOnly_isDisabled() async throws { + let container = try await Self.makeContainer(holdings: [ + SessionContainer.Holding(mint: .usdf, quarks: 10_000_000), + ]) + + #expect(Self.canConvert(.usdf, in: container) == false) + } + + @Test("Dollars plus a held currency: Convert is enabled and opens on that currency") + func dollarsAndHeldCurrency_isEnabled() async throws { + let container = try await Self.makeContainer(holdings: [ + SessionContainer.Holding(mint: .usdf, quarks: 10_000_000), + SessionContainer.Holding(mint: Self.jeffy, quarks: Self.jeffyQuarks), + ]) + + #expect(Self.canConvert(.usdf, in: container)) + + let usdf = try #require(container.session.balance(for: .usdf)) + let convert = ConvertAmountViewModel( + sourceBalance: usdf, + session: container.session, + ratesController: container.ratesController + ) + #expect(convert.destinationMint == .jeffy) + } + + @Test("Only other holding is a zero balance: Convert is disabled") + func zeroBalanceOtherHolding_isDisabled() async throws { + let container = try await Self.makeContainer(holdings: [ + SessionContainer.Holding(mint: .usdf, quarks: 10_000_000), + SessionContainer.Holding(mint: Self.jeffy, quarks: 0), + ]) + + #expect(Self.canConvert(.usdf, in: container) == false) + } + + @Test("No balances yet: Convert on the Dollars screen is disabled") + func noBalances_isDisabled() async throws { + let container = try await Self.makeContainer(holdings: []) + + #expect(Self.canConvert(.usdf, in: container) == false) + } + + @Test("Another currency's screen keeps Convert enabled with nothing else held") + func nonDollarsScreen_isEnabled() async throws { + let container = try await Self.makeContainer(holdings: [ + SessionContainer.Holding(mint: Self.jeffy, quarks: Self.jeffyQuarks), + ]) + + #expect(Self.canConvert(.jeffy, in: container)) + } +}