diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift index e57ca07e7..c01bf194d 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift @@ -375,9 +375,29 @@ private struct PaymentRequestPaySheetPresenter: ViewModifier { @Environment(Session.self) private var session @Environment(SessionContainer.self) private var sessionContainer + @Environment(AppRouter.self) private var router + + /// Add More Cash tapped while the swipe sheet was up; routed once the sheet has dismissed. + @State private var pendingDeposit: PublicKey? func body(content: Content) -> some View { - content.sheet(isPresented: $flow.isPaying) { + content + .onChange(of: flow.payment?.depositMint) { _, mint in + guard let mint else { return } + flow.payment?.depositMint = nil + if flow.isPaying { + // The router can't dismiss this local sheet, so close it and navigate after. + pendingDeposit = mint + flow.isPaying = false + } else { + router.navigate(to: .currencyInfoForDeposit(mint)) + } + } + .sheet(isPresented: $flow.isPaying, onDismiss: { + guard let mint = pendingDeposit else { return } + pendingDeposit = nil + router.navigate(to: .currencyInfoForDeposit(mint)) + }) { // The share reads paid once the payment lands; the sheet stays up through its success state. if let payment = flow.payment, let request = payment.request, let creator = request.creator { PaymentRequestPaySheet( diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestPayment.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestPayment.swift index 4596bb39f..605071e20 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestPayment.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestPayment.swift @@ -36,6 +36,8 @@ final class PaymentRequestPayment { let requestID: PaymentRequestID private(set) var selectedBalance: ExchangedBalance? + /// Set when the insufficient-funds dialog's Add Money is tapped; the presenter routes to the deposit and clears it. + var depositMint: PublicKey? @ObservationIgnored private let requests: PaymentRequests @ObservationIgnored private let session: Session @@ -96,6 +98,20 @@ final class PaymentRequestPayment { return intentID != nil && share != nil ? .ready : .nothingToPay } + /// The share in the request's currency, nil when the request's currency or amount can't be read. + private func shareFiat(_ share: GroupPaymentRequest.Share, of request: GroupPaymentRequest) -> FiatAmount? { + guard let currency = request.split.flatMap({ try? CurrencyCode(currencyCode: $0.currency) }), + let value = Decimal(string: "\(share.nativeAmount)") else { return nil } + return FiatAmount(value: value, currency: currency) + } + + /// `fiat` priced into `mint` at a fresh rate. + private func price(_ fiat: FiatAmount, mint: PublicKey) async -> (amount: ExchangedFiat, pin: VerifiedState)? { + guard let pin = await ratesController.currentPinnedState(for: fiat.currency, mint: mint), + let amount = session.price(fiat, mint: mint, at: pin) else { return nil } + return (amount, pin) + } + /// Pays exactly the share: its amount in the request's currency, priced into the picked token. func submit() async -> Outcome { if needsRefetch { @@ -107,8 +123,7 @@ final class PaymentRequestPayment { guard let request, let share, let creator = request.creator, let intentID, let intentKey = try? PublicKey(intentID), let chatID = request.chatID, - let currency = request.split.flatMap({ try? CurrencyCode(currencyCode: $0.currency) }), - let value = Decimal(string: "\(share.nativeAmount)") else { + let fiat = shareFiat(share, of: request) else { showSendError() return .failed } @@ -124,8 +139,7 @@ final class PaymentRequestPayment { } let mint = selectedBalance.stored.mint - guard let pin = await ratesController.currentPinnedState(for: currency, mint: mint), - let amount = session.price(FiatAmount(value: value, currency: currency), mint: mint, at: pin) else { + guard let (amount, pin) = await price(fiat, mint: mint) else { ErrorReporting.captureError( RatesController.Error.exchangeRateUnavailable, reason: "No fresh rate at request payment", @@ -138,19 +152,10 @@ final class PaymentRequestPayment { return .failed } + // Backstop: the balance can drop between the pre-check and the swipe. switch session.hasSufficientFunds(for: amount) { case .insufficient(let shortfall): - if let shortfall { - session.dialogItem = .error( - title: "You're \(shortfall.nativeAmount.formatted()) Short", - subtitle: "Add more cash, or try again with a lower amount" - ) - } else { - session.dialogItem = .error( - title: "You Need More Cash", - subtitle: "Please add more cash, or try again with a lower amount" - ) - } + showInsufficientFunds(shortfall: shortfall) return .failed case .sufficient: break @@ -210,6 +215,19 @@ final class PaymentRequestPayment { } } + private func showInsufficientFunds(shortfall: ExchangedFiat?) { + let title = shortfall.map { "You're \($0.nativeAmount.formatted()) Short" } ?? "You Need More Cash" + let subtitle = shortfall == nil + ? "Please add more cash, or try again with a lower amount" + : "Add more cash, or try again with a lower amount" + session.dialogItem = .error(title: title, subtitle: subtitle) { + .destructive("Add More Cash") { [weak self] in + self?.depositMint = self?.selectedBalance?.stored.mint + }; + .dismiss(kind: .subtle) + } + } + private func showRequestClosed() { session.dialogItem = .error( title: "Request Closed", diff --git a/Flipcash/Core/Screens/Send/CurrencySendFlow.swift b/Flipcash/Core/Screens/Send/CurrencySendFlow.swift index 14ac0681e..9eaffabff 100644 --- a/Flipcash/Core/Screens/Send/CurrencySendFlow.swift +++ b/Flipcash/Core/Screens/Send/CurrencySendFlow.swift @@ -149,6 +149,10 @@ private struct CurrencySelectRecipientScreen: View { @State private var query = "" @State private var confirming: ConfirmTarget? @State private var handoff = CurrencySendHandoff() + /// Add More Cash tapped while the confirm sheet was up; routed once the sheet has dismissed. + @State private var pendingDeposit: PublicKey? + /// The last confirmed send's model; kept after the sheet goes so its Add More Cash can still route. + @State private var lastModel: SendAmountViewModel? /// What the confirm sheet is open for: the recipient, and the send view model built for them. private struct ConfirmTarget: Identifiable { @@ -198,9 +202,24 @@ private struct CurrencySelectRecipientScreen: View { } .navigationTitle("Select Recipient") .navigationBarTitleDisplayMode(.inline) + .onChange(of: lastModel?.depositMint) { _, mint in + guard let mint else { return } + lastModel?.depositMint = nil + if confirming != nil { + // The router can't dismiss this local sheet, so close it and navigate after. + pendingDeposit = mint + confirming = nil + } else { + router.navigate(to: .currencyInfoForDeposit(mint)) + } + } .sheet(item: $confirming, onDismiss: { // After the dismiss animation: nothing on the stack moves while the sheet is up. if handoff.sheetDidDismiss() { leaveFlow() } + if let mint = pendingDeposit { + pendingDeposit = nil + router.navigate(to: .currencyInfoForDeposit(mint)) + } }) { target in CurrencySendConfirmSheet(target: target.model, amount: viewModel.enteredTotal, name: target.name, username: target.username, userID: target.userID, picture: target.picture) { handoff.sendSucceeded() @@ -289,6 +308,7 @@ private struct CurrencySelectRecipientScreen: View { ) else { return } let model = SendAmountViewModel(sessionContainer: sessionContainer, target: target, mint: viewModel.mint) model.enteredAmount = viewModel.enteredAmount + lastModel = model confirming = ConfirmTarget( id: chat.id, model: model, diff --git a/Flipcash/Core/Screens/Send/GroupSendFlow.swift b/Flipcash/Core/Screens/Send/GroupSendFlow.swift index 6db8dbdde..c700e693f 100644 --- a/Flipcash/Core/Screens/Send/GroupSendFlow.swift +++ b/Flipcash/Core/Screens/Send/GroupSendFlow.swift @@ -59,7 +59,8 @@ private struct GroupSendFlowContent: View { // Like the share-to-chats screen, the flow closes only from its X. .interactiveDismissDisabled() .onChange(of: viewModel.depositMint) { _, mint in - guard let mint else { return } + // Once people are being picked, Select People owns this: its confirm sheet may be up. + guard path.isEmpty, let mint else { return } viewModel.depositMint = nil router.navigate(to: .currencyInfoForDeposit(mint)) } @@ -132,9 +133,13 @@ private struct GroupSendAmountScreen: View { private struct GroupSendSelectPeopleScreen: View { + @Environment(AppRouter.self) private var router + let viewModel: GroupSendViewModel @State private var isConfirming = false + /// Add Money tapped while the confirm sheet was up; routed once the sheet has dismissed. + @State private var pendingDeposit: PublicKey? var body: some View { Background(color: .backgroundMain) { @@ -163,7 +168,22 @@ private struct GroupSendSelectPeopleScreen: View { .task(id: viewModel.conversationID) { await viewModel.refreshMembers() } - .sheet(isPresented: $isConfirming) { + .onChange(of: viewModel.depositMint) { _, mint in + guard let mint else { return } + viewModel.depositMint = nil + if isConfirming { + // The router can't dismiss this local sheet, so close it and navigate after. + pendingDeposit = mint + isConfirming = false + } else { + router.navigate(to: .currencyInfoForDeposit(mint)) + } + } + .sheet(isPresented: $isConfirming, onDismiss: { + guard let mint = pendingDeposit else { return } + pendingDeposit = nil + router.navigate(to: .currencyInfoForDeposit(mint)) + }) { GroupSendConfirmSheet(viewModel: viewModel) } } diff --git a/FlipcashTests/InsufficientFundsSheetTests.swift b/FlipcashTests/InsufficientFundsSheetTests.swift new file mode 100644 index 000000000..161d39dd4 --- /dev/null +++ b/FlipcashTests/InsufficientFundsSheetTests.swift @@ -0,0 +1,40 @@ +// +// InsufficientFundsSheetTests.swift +// FlipcashTests +// + +import Foundation +import Testing +import FlipcashCore +import FlipcashUI +@testable import Flipcash + +@MainActor +@Suite("Insufficient funds over a swipe sheet") +struct InsufficientFundsSheetTests { + + // MARK: - Currency Info send + + @Test("Add More Cash on a short Currency Info send routes to the deposit for the sent currency") + func currencySendAddMoney() async { + let mock = MockSession() + let viewModel = SendAmountViewModel( + sessionContainer: .mock, + target: .contact(SendAmountViewModelTests.makeContact()), + mint: nil, + sender: mock, + resolver: mock + ) + viewModel.selectCurrencyAction(exchangedBalance: ExchangedBalance.makeTest(quarks: 0)) + viewModel.enteredAmount = "5" + + #expect(await viewModel.sendAction() == .failed) + let dialog = viewModel.session.dialogItem + let add = dialog?.actions.first { $0.title == "Add More Cash" } + #expect(add != nil) + add?.perform(isChecked: false) + + #expect(viewModel.depositMint == viewModel.selectedBalance?.stored.mint) + #expect(viewModel.depositMint != nil) + } +}