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
49 changes: 39 additions & 10 deletions Bitkit/Views/Wallets/Send/SendConfirmationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,15 @@ struct SendConfirmationView: View {
@State private var hasStartedAutomaticPayment = false
@State private var requiresPaymentConfirmation = false
@State private var walletSwitchContext: ConfirmationContext?
@State private var walletSwitchRun: WalletSwitchRun?
@State private var presentationID: UUID?

private struct WalletSwitchRun {
let id: UUID
let context: ConfirmationContext
let task: Task<Void, Never>
}

private struct ConfirmationContext: Equatable {
let presentationID: UUID
let walletType: WalletType
Expand Down Expand Up @@ -436,23 +443,16 @@ struct SendConfirmationView: View {
walletSwitchContext = nil
}
}
.task(id: confirmationContext == walletSwitchContext ? walletSwitchContext : nil) {
guard let context = walletSwitchContext, !Task.isCancelled, confirmationContext == context else { return }
if context.walletType == .lightning {
await calculateTransactionFee()
} else {
await onSwitchToOnchainWallet(context: context)
}
if !Task.isCancelled, walletSwitchContext == context {
walletSwitchContext = nil
}
.onChange(of: confirmationContext == walletSwitchContext ? walletSwitchContext : nil, initial: true) { _, context in
startWalletSwitch(context: context)
}
.onChange(of: walletSwitchContext) { previous, context in
// Clearing the context cancels the switch task, so the start runs outside it with the current setup state.
guard previous != nil, context == nil, confirmationContext != nil, preparingRequest == nil else { return }
Task { @MainActor in await startAutomaticPaymentIfNeeded() }
}
.onDisappear {
startWalletSwitch(context: nil)
if !isEditingSendDetails || sendContext != walletSwitchContext {
walletSwitchContext = nil
}
Expand Down Expand Up @@ -1742,6 +1742,35 @@ struct SendConfirmationView: View {
}
}

/// SwiftUI can deliver the same switch twice when the confirmation reappears, so a live run for the context is kept.
@MainActor
private func startWalletSwitch(context: ConfirmationContext?) {
if let run = walletSwitchRun, run.context == context, !run.task.isCancelled {
return
}
walletSwitchRun?.task.cancel()
walletSwitchRun = nil
guard let context else { return }
let runID = UUID()
let task = Task { @MainActor in
defer {
if walletSwitchRun?.id == runID {
walletSwitchRun = nil
}
}
guard !Task.isCancelled, walletSwitchContext == context, confirmationContext == context else { return }
if context.walletType == .lightning {
await calculateTransactionFee()
} else {
await onSwitchToOnchainWallet(context: context)
}
if !Task.isCancelled, walletSwitchContext == context {
walletSwitchContext = nil
}
}
walletSwitchRun = WalletSwitchRun(id: runID, context: context, task: task)
}

@MainActor
private func onSwitchToOnchainWallet(context: ConfirmationContext) async {
func isCurrentSend() -> Bool {
Expand Down
30 changes: 24 additions & 6 deletions BitkitTests/SendConfirmationLifecycleTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ final class SendConfirmationLifecycleTests: XCTestCase {
snapshotAppGroupDefaults("home_screen_display_currency_code_v1", "home_screen_display_currency_symbol_v1")
FeeEstimatesManager().devOverrideFeeEstimates = true
for operation in [ControlledSendWallet.Operation.manualUtxos, .selection] {
let wallet = ControlledSendWallet(operation: operation)
let wallet = ControlledSendWallet(operation: operation, suspendsResumedPreparation: true)
let app = AppViewModel()
let sheets = SheetViewModel()
sheets.activeSheetConfiguration = SheetConfiguration(id: .send, data: nil)
Expand All @@ -304,11 +304,19 @@ final class SendConfirmationLifecycleTests: XCTestCase {
XCTAssertEqual(wallet.preparationCount, 1)
XCTAssertEqual(navigation.path, [.confirm, .tag])

// The resumed preparation waits until the pop back from tags has settled, so its push never overlaps that transition.
navigation.path.removeLast()
try await navigation.waitForAppearance(operation == .manualUtxos ? .utxoSelection : .confirm)
let deadline = ContinuousClock.now + .seconds(3)
while wallet.preparationCount < 2, ContinuousClock.now < deadline {
try await Task.sleep(for: .milliseconds(10))
try await navigation.waitForAppearance(.confirm)
await fulfillment(of: [wallet.resumedStarted], timeout: 3)
XCTAssertEqual(wallet.preparationCount, 2)
wallet.resume()
if operation == .manualUtxos {
try await navigation.waitForAppearance(.utxoSelection)
} else {
let deadline = ContinuousClock.now + .seconds(3)
while wallet.selectedUtxos == nil, ContinuousClock.now < deadline {
try await Task.sleep(for: .milliseconds(10))
}
}
XCTAssertEqual(wallet.preparationCount, 2)
XCTAssertEqual(app.selectedWalletToPayFrom, .onchain)
Expand Down Expand Up @@ -587,15 +595,18 @@ private final class ControlledSendWallet: WalletViewModel {

let started = XCTestExpectation(description: "Send preparation suspended")
let finished = XCTestExpectation(description: "Send preparation resumed")
let resumedStarted = XCTestExpectation(description: "Resumed send preparation suspended")
private let operation: Operation
private let fails: Bool
private let suspendsResumedPreparation: Bool
private var continuation: CheckedContinuation<Void, Never>?
private(set) var wasCancelled = false
private(set) var preparationCount = 0

init(operation: Operation, fails: Bool = false) {
init(operation: Operation, fails: Bool = false, suspendsResumedPreparation: Bool = false) {
self.operation = operation
self.fails = fails
self.suspendsResumedPreparation = suspendsResumedPreparation
super.init(
transferService: TransferService(lightningService: .shared, blocktankService: Bitkit.CoreService.shared.blocktank),
sheetViewModel: SheetViewModel(),
Expand Down Expand Up @@ -643,6 +654,13 @@ private final class ControlledSendWallet: WalletViewModel {

private func suspend() async throws {
preparationCount += 1
if preparationCount == 2, suspendsResumedPreparation {
await withCheckedContinuation { continuation in
self.continuation = continuation
resumedStarted.fulfill()
}
return
}
guard preparationCount == 1 else { return }
await withCheckedContinuation { continuation in
self.continuation = continuation
Expand Down
Loading