Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
48 changes: 33 additions & 15 deletions Flipcash/Core/Screens/PaymentRequest/PaymentRequestPayment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
Expand All @@ -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",
Expand All @@ -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
Expand Down Expand Up @@ -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",
Expand Down
20 changes: 20 additions & 0 deletions Flipcash/Core/Screens/Send/CurrencySendFlow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down
24 changes: 22 additions & 2 deletions Flipcash/Core/Screens/Send/GroupSendFlow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}
}
Expand Down
40 changes: 40 additions & 0 deletions FlipcashTests/InsufficientFundsSheetTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading