From 412f19e263f692f3c64b6e8c5733fd8e700e1e6e Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Tue, 23 Dec 2025 12:44:34 +0100 Subject: [PATCH 1/3] fix(ui): toasts not showing after soft crash --- Bitkit/Managers/ToastWindowManager.swift | 54 +++++++++++++++++++-- Bitkit/Views/Settings/DevSettingsView.swift | 6 +++ 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/Bitkit/Managers/ToastWindowManager.swift b/Bitkit/Managers/ToastWindowManager.swift index 71ed0299..5e620006 100644 --- a/Bitkit/Managers/ToastWindowManager.swift +++ b/Bitkit/Managers/ToastWindowManager.swift @@ -35,13 +35,22 @@ class ToastWindowManager: ObservableObject { } func showToast(_ toast: Toast) { + // Ensure window is set up before showing toast + ensureWindowExists() + + // If window still doesn't exist after trying to set it up, log and return + guard let window = toastWindow else { + Logger.error("ToastWindowManager: Cannot show toast - window not available") + return + } + // Dismiss any existing toast first cancelAutoHide() - toastWindow?.hasToast = false - toastWindow?.toastFrame = .zero + window.hasToast = false + window.toastFrame = .zero // Update window's toast state for hit testing - toastWindow?.hasToast = true + window.hasToast = true // Show the toast with animation withAnimation(.easeInOut(duration: 0.4)) { @@ -68,7 +77,7 @@ class ToastWindowManager: ObservableObject { } func pauseAutoHide() { - guard autoHideStartTime != nil else { return } // Already paused or no auto-hide + guard autoHideStartTime != nil else { return } // No active auto-hide to pause cancelAutoHide() // Calculate remaining time @@ -124,11 +133,32 @@ class ToastWindowManager: ObservableObject { autoHideDuration = 0 } + private func ensureWindowExists() { + // Check if window already exists and is still valid + if let existingWindow = toastWindow, + existingWindow.windowScene != nil, + !existingWindow.isHidden + { + return + } + + // Window doesn't exist or is invalid, try to set it up + setupToastWindow() + } + private func setupToastWindow() { - guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { + // Try to find an active window scene + guard let windowScene = findActiveWindowScene() else { + Logger.warn("ToastWindowManager: No active window scene available") return } + // Clean up old window if it exists + if let oldWindow = toastWindow { + oldWindow.isHidden = true + oldWindow.rootViewController = nil + } + let window = PassThroughWindow(windowScene: windowScene) window.windowLevel = UIWindow.Level.alert + 1 // Above alerts and sheets window.backgroundColor = .clear @@ -143,6 +173,20 @@ class ToastWindowManager: ObservableObject { toastWindow = window toastHostingController = hostingController } + + private func findActiveWindowScene() -> UIWindowScene? { + // Try to find an active window scene from connected scenes + for scene in UIApplication.shared.connectedScenes { + if let windowScene = scene as? UIWindowScene, + windowScene.activationState == .foregroundActive || windowScene.activationState == .foregroundInactive + { + return windowScene + } + } + + // Fallback to any window scene if no active one found + return UIApplication.shared.connectedScenes.first as? UIWindowScene + } } // Custom window that only intercepts touches on interactive elements diff --git a/Bitkit/Views/Settings/DevSettingsView.swift b/Bitkit/Views/Settings/DevSettingsView.swift index d6780b46..1c76722b 100644 --- a/Bitkit/Views/Settings/DevSettingsView.swift +++ b/Bitkit/Views/Settings/DevSettingsView.swift @@ -102,6 +102,12 @@ struct DevSettingsView: View { SettingsListLabel(title: "Test Push Notification", rightIcon: nil) } + Button { + fatalError("Simulate Crash") + } label: { + SettingsListLabel(title: "Simulate Crash", rightIcon: nil) + } + Button { Task { do { From 6f91ccf119a1090a42b04067aa4287128d66a5a1 Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Tue, 23 Dec 2025 17:20:44 +0100 Subject: [PATCH 2/3] fix(send): max send amount incorrect for onchain invoice --- Bitkit/Views/Wallets/Send/SendAmountView.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Bitkit/Views/Wallets/Send/SendAmountView.swift b/Bitkit/Views/Wallets/Send/SendAmountView.swift index 33b55542..1bfe9ae0 100644 --- a/Bitkit/Views/Wallets/Send/SendAmountView.swift +++ b/Bitkit/Views/Wallets/Send/SendAmountView.swift @@ -174,6 +174,14 @@ struct SendAmountView: View { maxSendableAmount = nil } } + .onChange(of: wallet.selectedFeeRateSatsPerVByte) { _ in + // Recalculate max sendable amount when fee rate becomes available or changes + if app.selectedWalletToPayFrom == .onchain { + Task { + await calculateMaxSendableAmount() + } + } + } } private func onContinue() async { @@ -258,8 +266,8 @@ struct SendAmountView: View { } catch { Logger.error("Failed to calculate max sendable amount: \(error)") await MainActor.run { - // Fall back to total balance if calculation fails - maxSendableAmount = UInt64(wallet.spendableOnchainBalanceSats) + // Keep as nil on error - availableAmount will fall back to total balance + maxSendableAmount = nil } } } From 0be7feba88358bfb48f463dfca29c7021fa8d522 Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Tue, 23 Dec 2025 19:24:00 +0100 Subject: [PATCH 3/3] chore: allow manually running CI on draft PR --- Bitkit/ViewModels/AmountInputViewModel.swift | 18 +++++++++++-- BitkitTests/NumberPadTests.swift | 27 ++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/Bitkit/ViewModels/AmountInputViewModel.swift b/Bitkit/ViewModels/AmountInputViewModel.swift index fd704a69..c8b80eb0 100644 --- a/Bitkit/ViewModels/AmountInputViewModel.swift +++ b/Bitkit/ViewModels/AmountInputViewModel.swift @@ -96,8 +96,12 @@ class AmountInputViewModel: ObservableObject { amountSats = newAmountSats displayText = formatDisplayTextFromAmount(newAmountSats, currency: currency) // Update raw input text based on the formatted display + // Remove formatting separators (spaces for modern Bitcoin, commas for fiat) if currency.primaryDisplay == .fiat { rawInputText = displayText.replacingOccurrences(of: ",", with: "") + } else if currency.displayUnit == .modern { + // Modern Bitcoin uses spaces as grouping separators + rawInputText = displayText.replacingOccurrences(of: " ", with: "") } else { rawInputText = displayText } @@ -115,8 +119,12 @@ class AmountInputViewModel: ObservableObject { if amountSats > 0 { displayText = formatDisplayTextFromAmount(amountSats, currency: currency) // Update raw input text based on the formatted display + // Remove formatting separators (spaces for modern Bitcoin, commas for fiat) if currency.primaryDisplay == .fiat { rawInputText = displayText.replacingOccurrences(of: ",", with: "") + } else if currency.displayUnit == .modern { + // Modern Bitcoin uses spaces as grouping separators + rawInputText = displayText.replacingOccurrences(of: " ", with: "") } else { rawInputText = displayText } @@ -135,8 +143,14 @@ class AmountInputViewModel: ObservableObject { // First convert fiat to sats, then format for Bitcoin display let cleanFiat = currentRawInput.replacingOccurrences(of: ",", with: "") if let fiatValue = Double(cleanFiat), let sats = currency.convert(fiatAmount: fiatValue) { - rawInputText = formatBitcoinFromSats(sats, isModern: currency.displayUnit == .modern) - displayText = rawInputText + let formatted = formatBitcoinFromSats(sats, isModern: currency.displayUnit == .modern) + displayText = formatted + // Remove spaces from rawInputText for modern Bitcoin + if currency.displayUnit == .modern { + rawInputText = formatted.replacingOccurrences(of: " ", with: "") + } else { + rawInputText = formatted + } } } } diff --git a/BitkitTests/NumberPadTests.swift b/BitkitTests/NumberPadTests.swift index dc0f40e3..e5d8d2d1 100644 --- a/BitkitTests/NumberPadTests.swift +++ b/BitkitTests/NumberPadTests.swift @@ -179,6 +179,33 @@ final class NumberPadTests: XCTestCase { XCTAssertEqual(viewModel.amountSats, 0) } + func testDeleteAfterMaxAmountModernBitcoin() { + let viewModel = AmountInputViewModel() + let currency = mockCurrency(primaryDisplay: .bitcoin, displayUnit: .modern) + + // Set max amount using updateFromSats (simulates tapping max button) + // This creates formatted text with spaces (e.g., "100 000 000") + viewModel.updateFromSats(100_000_000, currency: currency) + XCTAssertEqual(viewModel.displayText, "100 000 000") + XCTAssertEqual(viewModel.amountSats, 100_000_000) + + // Delete should remove digits, not spaces + // After first delete, should be "10 000 000" (one digit removed) + viewModel.handleNumberPadInput("delete", currency: currency) + XCTAssertEqual(viewModel.displayText, "10 000 000") + XCTAssertEqual(viewModel.amountSats, 10_000_000) + + // Delete again + viewModel.handleNumberPadInput("delete", currency: currency) + XCTAssertEqual(viewModel.displayText, "1 000 000") + XCTAssertEqual(viewModel.amountSats, 1_000_000) + + // Delete again + viewModel.handleNumberPadInput("delete", currency: currency) + XCTAssertEqual(viewModel.displayText, "100 000") + XCTAssertEqual(viewModel.amountSats, 100_000) + } + // MARK: - Leading Zero Tests func testLeadingZeroBehavior() {