diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 786f822d8..7625b86c2 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -359,7 +359,11 @@ class AppViewModel: ObservableObject { private let navigationViewModel: NavigationViewModel private let scanPaymentOperations: ScanPaymentOperations private let usdtBalance: () -> UInt64? - private var scannedDataHandlingId: UUID? + @Published private var scannedDataHandlingId: UUID? + var isHandlingScannedData: Bool { + scannedDataHandlingId != nil + } + private var manualEntryValidationSequence: UInt64 = 0 // Combine infrastructure for debounced validation diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index 4b7f88a64..861f25afe 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -179,6 +179,15 @@ struct SendSheet: View { config.preparation != nil && incomingPaymentRequest == nil } + private var isMissingLnurlPayData: Bool { + guard app.lnurlPayData == nil, !app.isHandlingScannedData, !isSubmittingPayment else { return false } + return Self.isLnurlPayRoute(navigationPath.last ?? currentRoot) + } + + private static func isLnurlPayRoute(_ route: SendRoute) -> Bool { + route == .lnurlPayAmount || route == .lnurlPayConfirm + } + private var shouldDismissUnavailableRequest: Bool { guard let request = incomingPaymentRequest else { return false } return Self.shouldDismissUnavailableRequest( @@ -285,6 +294,10 @@ struct SendSheet: View { guard shouldDismiss else { return } sheets.hideSheetIfActive(isEmbedded ? .subscription : .send, reason: "Incoming payment request is no longer available") } + .onChange(of: isMissingLnurlPayData, initial: true) { _, isMissing in + guard isMissing, isCurrentPresentation else { return } + exitMissingLnurlPayment() + } .onChange(of: shouldShowSyncOverlay, initial: true) { _, isShowing in Logger.debug("shouldShowSyncOverlay: \(isShowing) (node: \(wallet.nodeLifecycleState))", context: "SendSheet") } @@ -1249,6 +1262,14 @@ struct SendSheet: View { navigationPath = [route] } + private func exitMissingLnurlPayment() { + if !Self.isLnurlPayRoute(currentRoot), let index = navigationPath.firstIndex(where: Self.isLnurlPayRoute) { + navigationPath.removeSubrange(index...) + } else { + sheets.hideSheetIfActive(isEmbedded ? .subscription : .send, reason: "LNURL payment data is no longer available") + } + } + private func replaceRootRoute(with route: SendRoute) { rootOverride = nil rootRoute = route diff --git a/BitkitTests/SendSheetLifecycleTests.swift b/BitkitTests/SendSheetLifecycleTests.swift index 7e4158173..8bd8ad052 100644 --- a/BitkitTests/SendSheetLifecycleTests.swift +++ b/BitkitTests/SendSheetLifecycleTests.swift @@ -80,11 +80,40 @@ final class SendSheetLifecycleTests: XCTestCase { XCTAssertNil(app.lnurlPayData, "Ending the presentation must still clean up") } - func testEmbeddedSendExitCleansStateWhileSubscriptionRemainsActive() async throws { - let (app, sheets, prompt, window) = makeHost(isEmbedded: true) + func testMissingLnurlDataClosesItsPresentation() async throws { + let (app, sheets, _, window) = makeHost() defer { window.isHidden = true; window.rootViewController = nil } app.lnurlPayData = makeLnurlData() app.selectedWalletToPayFrom = .lightning + sheets.showSheet(.send, data: SendConfig(view: .lnurlPayConfirm)) + try await Task.sleep(for: .seconds(1)) + XCTAssertEqual(sheets.activeSheetConfiguration?.id, .send) + app.lnurlPayData = nil + try await Task.sleep(for: .milliseconds(500)) + XCTAssertNil(sheets.activeSheetConfiguration, "A confirmation without LNURL data must close instead of staying blank") + } + + func testMissingLnurlDataClosesAmountRootedPresentation() async throws { + let (app, sheets, _, window) = makeHost() + defer { window.isHidden = true; window.rootViewController = nil } + app.lnurlPayData = LnurlPayData( + uri: "https://example.com/variable", callback: "https://example.com/callback", + minSendable: 149_500, maxSendable: 200_999, + metadataStr: "[]", commentAllowed: 12, allowsNostr: false, nostrPubkey: nil + ) + app.selectedWalletToPayFrom = .lightning + sheets.showSheet(.send, data: SendConfig(view: .lnurlPayAmount)) + try await Task.sleep(for: .seconds(1)) + XCTAssertEqual(sheets.activeSheetConfiguration?.id, .send) + app.lnurlPayData = nil + try await Task.sleep(for: .milliseconds(500)) + XCTAssertNil(sheets.activeSheetConfiguration, "An amount root without LNURL data must close, not stay unusable") + } + + func testEmbeddedSendExitCleansStateWhileSubscriptionRemainsActive() async throws { + // Subscriptions route embedded Send to LNURL confirmation only once its data is set. + let (app, sheets, prompt, window) = makeHost(isEmbedded: true, lnurlPayData: makeLnurlData()) + defer { window.isHidden = true; window.rootViewController = nil } try await Task.sleep(for: .seconds(1)) let presentationID = try XCTUnwrap(sheets.activeSheetConfiguration?.presentationID) XCTAssertEqual(app.lnurlPayData?.minSendable, 222_222) @@ -101,10 +130,17 @@ final class SendSheetLifecycleTests: XCTestCase { commentAllowed: nil, allowsNostr: false, nostrPubkey: nil) } - private func makeHost(isEmbedded: Bool = false) -> (AppViewModel, SheetViewModel, ScanPromptState, UIWindow) { + private func makeHost( + isEmbedded: Bool = false, + lnurlPayData: LnurlPayData? = nil + ) -> (AppViewModel, SheetViewModel, ScanPromptState, UIWindow) { snapshotAppDefaultsDomain() snapshotAppGroupDefaults("home_screen_display_currency_code_v1", "home_screen_display_currency_symbol_v1") let app = AppViewModel() + if let lnurlPayData { + app.lnurlPayData = lnurlPayData + app.selectedWalletToPayFrom = .lightning + } let wallet = WalletViewModel() wallet.nodeLifecycleState = .running let sheets = SheetViewModel()