From 4e9ebdc5efc99a67978ff166c2c6f61537310542 Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Tue, 3 Feb 2026 17:14:10 +0100 Subject: [PATCH 1/2] feat(scan): add camera permission UI --- Bitkit/AppScene.swift | 2 + .../icons/camera.imageset/Contents.json | 15 +++++ .../icons/camera.imageset/camera.pdf | Bin 0 -> 4915 bytes Bitkit/Components/Scanner.swift | 58 ++++++++++++++---- Bitkit/MainNavView.swift | 4 +- Bitkit/Managers/CameraManager.swift | 49 +++++++++++++++ .../Localization/en.lproj/Localizable.strings | 6 +- 7 files changed, 118 insertions(+), 16 deletions(-) create mode 100644 Bitkit/Assets.xcassets/icons/camera.imageset/Contents.json create mode 100644 Bitkit/Assets.xcassets/icons/camera.imageset/camera.pdf create mode 100644 Bitkit/Managers/CameraManager.swift diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 4b0268ce6..b7641fdc0 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -18,6 +18,7 @@ struct AppScene: View { @StateObject private var feeEstimatesManager: FeeEstimatesManager @StateObject private var transfer: TransferViewModel @StateObject private var widgets = WidgetsViewModel() + @StateObject private var cameraManager = CameraManager.shared @StateObject private var pushManager = PushNotificationManager.shared @StateObject private var scannerManager = ScannerManager() @StateObject private var settings = SettingsViewModel.shared @@ -124,6 +125,7 @@ struct AppScene: View { .environmentObject(activity) .environmentObject(transfer) .environmentObject(widgets) + .environmentObject(cameraManager) .environmentObject(pushManager) .environmentObject(scannerManager) .environmentObject(settings) diff --git a/Bitkit/Assets.xcassets/icons/camera.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/camera.imageset/Contents.json new file mode 100644 index 000000000..570b584b2 --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/camera.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "camera.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Bitkit/Assets.xcassets/icons/camera.imageset/camera.pdf b/Bitkit/Assets.xcassets/icons/camera.imageset/camera.pdf new file mode 100644 index 0000000000000000000000000000000000000000..bfab15e5912f687d48f6edf41706ae2454567f36 GIT binary patch literal 4915 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 literal 0 HcmV?d00001 diff --git a/Bitkit/Components/Scanner.swift b/Bitkit/Components/Scanner.swift index b23222d00..3359022fa 100644 --- a/Bitkit/Components/Scanner.swift +++ b/Bitkit/Components/Scanner.swift @@ -66,24 +66,58 @@ struct Scanner: View { let onScan: (String) async -> Void let onImageSelection: (PhotosPickerItem?) async -> Void + @EnvironmentObject private var cameraManager: CameraManager @State private var isTorchOn = false var body: some View { ZStack { - ScannerCamera( - isTorchOn: isTorchOn, - onScan: { uri in - await onScan(uri) - } - ) + if cameraManager.hasPermission { + ScannerCamera( + isTorchOn: isTorchOn, + onScan: { uri in + await onScan(uri) + } + ) - ScannerCornerButtons( - isTorchOn: $isTorchOn, - onImageSelection: { item in - await onImageSelection(item) - } - ) + ScannerCornerButtons( + isTorchOn: $isTorchOn, + onImageSelection: { item in + await onImageSelection(item) + } + ) + } else { + ScannerPermissionRequest(onRequestPermission: cameraManager.requestPermission) + } } .cornerRadius(16) + .onAppear { + guard !cameraManager.hasPermission else { return } + cameraManager.requestPermissionIfNeeded() + } + } +} + +struct ScannerPermissionRequest: View { + let onRequestPermission: () -> Void + + var body: some View { + Color.black + .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay { + VStack(spacing: 0) { + DisplayText(t("other__camera_no_title"), accentColor: .brandAccent) + .padding(.bottom, 8) + BodyMText(t("other__camera_no_text")) + .padding(.bottom, 32) + CustomButton( + title: t("other__camera_no_button"), + icon: Image("camera").foregroundColor(.textPrimary) + ) { + onRequestPermission() + } + } + .padding(.horizontal, 32) + .padding(.vertical, 16) + } } } diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index f3f5725e6..b7f1ed3e4 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -2,6 +2,7 @@ import SwiftUI struct MainNavView: View { @EnvironmentObject private var app: AppViewModel + @EnvironmentObject private var cameraManager: CameraManager @EnvironmentObject private var currency: CurrencyViewModel @EnvironmentObject private var navigation: NavigationViewModel @EnvironmentObject private var notificationManager: PushNotificationManager @@ -169,8 +170,9 @@ struct MainNavView: View { } .onChange(of: scenePhase) { newPhase in if newPhase == .active { - // Update notification permission in case user changed it in OS settings + // Update permissions in case user changed them in OS settings notificationManager.updateNotificationPermission() + cameraManager.refreshPermission() guard settings.readClipboard else { return } diff --git a/Bitkit/Managers/CameraManager.swift b/Bitkit/Managers/CameraManager.swift new file mode 100644 index 000000000..3ff59b7bf --- /dev/null +++ b/Bitkit/Managers/CameraManager.swift @@ -0,0 +1,49 @@ +import AVFoundation +import SwiftUI + +final class CameraManager: ObservableObject { + static let shared = CameraManager() + @Published var hasPermission: Bool = false + + init() { + refreshPermission() + } + + func refreshPermission() { + let granted = AVCaptureDevice.authorizationStatus(for: .video) == .authorized + DispatchQueue.main.async { + self.hasPermission = granted + } + } + + /// Call when the scanner appears; shows the system permission dialog only when status is .notDetermined (fresh install). + func requestPermissionIfNeeded() { + guard AVCaptureDevice.authorizationStatus(for: .video) == .notDetermined else { return } + AVCaptureDevice.requestAccess(for: .video) { granted in + DispatchQueue.main.async { + self.hasPermission = granted + } + } + } + + func requestPermission() { + let status = AVCaptureDevice.authorizationStatus(for: .video) + + switch status { + case .notDetermined: + requestPermissionIfNeeded() + case .denied, .restricted: + if let url = URL(string: UIApplication.openSettingsURLString) { + DispatchQueue.main.async { + UIApplication.shared.open(url) + } + } + case .authorized: + DispatchQueue.main.async { + self.hasPermission = true + } + @unknown default: + break + } + } +} diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index a508858dc..00bec6be1 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -399,9 +399,9 @@ "other__update_critical_title" = "Update\nBitkit now"; "other__update_critical_text" = "There is a critical update for Bitkit. You must update to continue using Bitkit."; "other__update_critical_button" = "Update Bitkit"; -"other__camera_ask_title" = "Permission to use camera"; -"other__camera_ask_msg" = "Bitkit needs permission to use your camera"; -"other__camera_no_text" = "It appears Bitkit does not have permission to access your camera.\n\nTo utilize this feature in the future you will need to enable camera permissions for this app from your phone\'s settings."; +"other__camera_no_title" = "Scan\nQR code"; +"other__camera_no_text" = "Allow camera access to scan bitcoin invoices and pay more quickly."; +"other__camera_no_button" = "Enable Camera"; "other__clipboard_redirect_title" = "Clipboard Data Detected"; "other__clipboard_redirect_msg" = "Do you want to be redirected to the relevant screen?"; "other__pay_insufficient_savings" = "Insufficient Savings"; From 148333ceb9482f46a9bd7f34ffb056d20a3bc838 Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Wed, 18 Mar 2026 09:25:56 +0100 Subject: [PATCH 2/2] feat(send): update design v59 --- .../icons/eye-slash.imageset/Contents.json | 15 + .../icons/eye-slash.imageset/eye-slash.pdf | Bin 0 -> 8251 bytes Bitkit/Components/ActivityIndicator.swift | 12 +- .../Components/Button/PrimaryButtonView.swift | 1 + Bitkit/Components/SendSectionView.swift | 25 + Bitkit/Components/SwipeButton.swift | 30 +- .../Localization/en.lproj/Localizable.strings | 6 +- Bitkit/Utilities/DateFormatterHelpers.swift | 20 + Bitkit/Views/Sheets/Sheet.swift | 2 +- Bitkit/Views/Wallets/Receive/ReceiveQr.swift | 1 + .../Views/Wallets/Send/SendAmountView.swift | 4 +- .../Wallets/Send/SendConfirmationView.swift | 536 +++++++++++------- .../Wallets/Send/SendUtxoSelectionView.swift | 28 +- 13 files changed, 434 insertions(+), 246 deletions(-) create mode 100644 Bitkit/Assets.xcassets/icons/eye-slash.imageset/Contents.json create mode 100644 Bitkit/Assets.xcassets/icons/eye-slash.imageset/eye-slash.pdf create mode 100644 Bitkit/Components/SendSectionView.swift diff --git a/Bitkit/Assets.xcassets/icons/eye-slash.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/eye-slash.imageset/Contents.json new file mode 100644 index 000000000..969759f92 --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/eye-slash.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "eye-slash.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Bitkit/Assets.xcassets/icons/eye-slash.imageset/eye-slash.pdf b/Bitkit/Assets.xcassets/icons/eye-slash.imageset/eye-slash.pdf new file mode 100644 index 0000000000000000000000000000000000000000..3690f6ae067591d8374e5aefe0587b73ec38afa7 GIT binary patch literal 8251 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: View { + private let title: String + @ViewBuilder private let content: () -> Content + + init(_ title: String, @ViewBuilder content: @escaping () -> Content) { + self.title = title + self.content = content + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + CaptionMText(title) + .padding(.bottom, 8) + + content() + + CustomDivider() + .padding(.top, 16) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} diff --git a/Bitkit/Components/SwipeButton.swift b/Bitkit/Components/SwipeButton.swift index 3d39e8a07..3078cf756 100644 --- a/Bitkit/Components/SwipeButton.swift +++ b/Bitkit/Components/SwipeButton.swift @@ -3,10 +3,11 @@ import SwiftUI struct SwipeButton: View { let title: String let accentColor: Color + /// Optional binding for swipe progress (0...1), e.g. to drive animations in the parent. + var swipeProgress: Binding? let onComplete: () async throws -> Void @State private var offset: CGFloat = 0 - @State private var isDragging = false @State private var isLoading = false private let buttonHeight: CGFloat = 76 @@ -14,6 +15,12 @@ struct SwipeButton: View { var body: some View { GeometryReader { geometry in + let maxOffset = max(1, geometry.size.width - buttonHeight) + let clampedOffset = max(0, min(offset, geometry.size.width - buttonHeight)) + let trailWidth = max(0, min(clampedOffset + (buttonHeight - innerPadding), geometry.size.width - innerPadding)) + let textProgress = offset / maxOffset + let halfWidth = geometry.size.width / 2 + ZStack(alignment: .leading) { // Track RoundedRectangle(cornerRadius: buttonHeight / 2) @@ -22,7 +29,7 @@ struct SwipeButton: View { // Colored trail RoundedRectangle(cornerRadius: buttonHeight / 2) .fill(accentColor.opacity(0.2)) - .frame(width: max(0, min(offset + (buttonHeight - innerPadding), geometry.size.width - innerPadding))) + .frame(width: trailWidth) .frame(height: buttonHeight - innerPadding) .padding(.horizontal, innerPadding / 2) .mask { @@ -34,52 +41,51 @@ struct SwipeButton: View { // Track text BodySSBText(title) .frame(maxWidth: .infinity, alignment: .center) - .opacity(Double(1.0 - (offset / (geometry.size.width - buttonHeight)))) + .opacity(Double(1.0 - textProgress)) - // Sliding circle + // Knob Circle() .fill(accentColor) .frame(width: buttonHeight - innerPadding, height: buttonHeight - innerPadding) .overlay( ZStack { if isLoading { - ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: .gray7)) + ActivityIndicator(theme: .dark) } else { Image("arrow-right") .resizable() .frame(width: 24, height: 24) .foregroundColor(.gray7) - .opacity(Double(1.0 - (offset / (geometry.size.width / 2)))) + .opacity(Double(1.0 - (offset / halfWidth))) Image("check-mark") .resizable() .frame(width: 32, height: 32) .foregroundColor(.gray7) - .opacity(Double(max(0, (offset - geometry.size.width / 2) / (geometry.size.width / 2)))) + .opacity(Double(max(0, (offset - halfWidth) / halfWidth))) } } ) .accessibilityIdentifier("GRAB") - .offset(x: max(0, min(offset, geometry.size.width - buttonHeight))) + .offset(x: clampedOffset) .padding(.horizontal, innerPadding / 2) .gesture( DragGesture() .onChanged { value in guard !isLoading else { return } withAnimation(.interactiveSpring()) { - isDragging = true offset = value.translation.width + swipeProgress?.wrappedValue = max(0, min(1, offset / maxOffset)) } } .onEnded { _ in guard !isLoading else { return } - isDragging = false withAnimation(.spring()) { let threshold = geometry.size.width * 0.7 if offset > threshold { Haptics.play(.medium) offset = geometry.size.width - buttonHeight + swipeProgress?.wrappedValue = 1 isLoading = true Task { @MainActor in do { @@ -88,6 +94,7 @@ struct SwipeButton: View { // Reset the slider back to the start on error withAnimation(.spring(duration: 0.3)) { offset = 0 + swipeProgress?.wrappedValue = 0 } // Adjust the delay to match animation duration @@ -98,6 +105,7 @@ struct SwipeButton: View { } } else { offset = 0 + swipeProgress?.wrappedValue = 0 } } } diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 00bec6be1..24c338942 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -83,6 +83,7 @@ "common__qr_code" = "QR Code"; "common__show_all" = "Show All"; "common__show_details" = "Show Details"; +"common__hide_details" = "Hide Details"; "common__success" = "Success"; "fee__instant__title" = "Instant"; "fee__instant__description" = "±2 seconds"; @@ -976,6 +977,7 @@ "wallet__create_wallet_mnemonic_error" = "Invalid recovery phrase."; "wallet__create_wallet_mnemonic_restore_error" = "Please double-check if your recovery phrase is accurate."; "wallet__send_bitcoin" = "Send Bitcoin"; +"wallet__send_from" = "From"; "wallet__send_to" = "To"; "wallet__recipient_contact" = "Contact"; "wallet__recipient_invoice" = "Paste Invoice"; @@ -985,7 +987,7 @@ "wallet__send_address_placeholder" = "Enter an invoice, address, or profile key"; "wallet__send_clipboard_empty_title" = "Clipboard Empty"; "wallet__send_clipboard_empty_text" = "Please copy an address or an invoice."; -"wallet__send_amount" = "Bitcoin Amount"; +"wallet__send_amount" = "Amount"; "wallet__send_max" = "MAX"; "wallet__send_done" = "DONE"; "wallet__send_available" = "Available"; @@ -993,7 +995,7 @@ "wallet__send_available_savings" = "Available (savings)"; "wallet__send_max_spending__title" = "Reserve Balance"; "wallet__send_max_spending__description" = "The maximum spendable amount is a bit lower due to a required reserve balance."; -"wallet__send_review" = "Review & Send"; +"wallet__send_review" = "Confirm"; "wallet__send_confirming_in" = "Confirming in"; "wallet__send_invoice_expiration" = "Invoice expiration"; "wallet__send_swipe" = "Swipe To Pay"; diff --git a/Bitkit/Utilities/DateFormatterHelpers.swift b/Bitkit/Utilities/DateFormatterHelpers.swift index ded79459d..3e24d1d3e 100644 --- a/Bitkit/Utilities/DateFormatterHelpers.swift +++ b/Bitkit/Utilities/DateFormatterHelpers.swift @@ -101,6 +101,26 @@ enum DateFormatterHelpers { return dateFormatter.string(from: date) } + /// Formats invoice expiry as relative time from now (e.g. "10 minutes", "1 hour"). + /// Uses BOLT11 semantics: expiry moment = creation timestamp + expiry seconds; displays time remaining until that moment. + /// - Parameters: + /// - timestampSeconds: Invoice creation time (Unix seconds). + /// - expirySeconds: Seconds from creation until the invoice expires (BOLT11 `x` field). + static func formatInvoiceExpiryRelative(timestampSeconds: UInt64, expirySeconds: UInt64) -> String { + let expiryTimestamp = Double(timestampSeconds) + Double(expirySeconds) + let now = Date().timeIntervalSince1970 + let secondsRemaining = expiryTimestamp - now + if secondsRemaining <= 0 { + return t("other__scan__error__expired") + } + let formatter = DateComponentsFormatter() + formatter.unitsStyle = .full + formatter.maximumUnitCount = 1 + formatter.allowedUnits = [.day, .hour, .minute, .second] + formatter.zeroFormattingBehavior = .dropAll + return formatter.string(from: secondsRemaining) ?? "—" + } + /// Formats a date for activity item display with relative formatting /// Matches the behavior of the React Native app's getActivityItemDate function /// - Parameter timestamp: Unix timestamp diff --git a/Bitkit/Views/Sheets/Sheet.swift b/Bitkit/Views/Sheets/Sheet.swift index e988cd44b..ee32d67df 100644 --- a/Bitkit/Views/Sheets/Sheet.swift +++ b/Bitkit/Views/Sheets/Sheet.swift @@ -86,7 +86,7 @@ struct SheetHeader: View { } } .padding(.top, 32) // Make room for the drag indicator - .padding(.bottom, 32) + .padding(.bottom, 24) } } diff --git a/Bitkit/Views/Wallets/Receive/ReceiveQr.swift b/Bitkit/Views/Wallets/Receive/ReceiveQr.swift index 03d5f3a5f..ebdadd03b 100644 --- a/Bitkit/Views/Wallets/Receive/ReceiveQr.swift +++ b/Bitkit/Views/Wallets/Receive/ReceiveQr.swift @@ -66,6 +66,7 @@ struct ReceiveQr: View { VStack(spacing: 0) { SheetHeader(title: t("wallet__receive_bitcoin")) .padding(.horizontal, 16) + .padding(.bottom, UIScreen.main.isSmall ? -16 : 0) SegmentedControl(selectedTab: $selectedTab, tabItems: availableTabItems) .padding(.bottom, 16) diff --git a/Bitkit/Views/Wallets/Send/SendAmountView.swift b/Bitkit/Views/Wallets/Send/SendAmountView.swift index 1bfe9ae0d..314b0ab10 100644 --- a/Bitkit/Views/Wallets/Send/SendAmountView.swift +++ b/Bitkit/Views/Wallets/Send/SendAmountView.swift @@ -3,8 +3,8 @@ import SwiftUI struct SendAmountView: View { @EnvironmentObject var app: AppViewModel @EnvironmentObject var currency: CurrencyViewModel - @EnvironmentObject var wallet: WalletViewModel @EnvironmentObject var settings: SettingsViewModel + @EnvironmentObject var wallet: WalletViewModel @Binding var navigationPath: [SendRoute] @@ -76,7 +76,7 @@ struct SendAmountView: View { if app.selectedWalletToPayFrom == .lightning { app.toast( - type: .warning, + type: .info, title: t("wallet__send_max_spending__title"), description: t("wallet__send_max_spending__description") ) diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 8853cd29e..518549842 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -11,6 +11,7 @@ struct SendConfirmationView: View { @EnvironmentObject var tagManager: TagManager @Binding var navigationPath: [SendRoute] + @State private var showDetails = false @State private var showPinCheck = false @State private var pinCheckContinuation: CheckedContinuation? @State private var showingBiometricError = false @@ -18,6 +19,23 @@ struct SendConfirmationView: View { @State private var transactionFee: Int = 0 @State private var routingFee: Int = 0 @State private var shouldUseSendAll: Bool = false + @State private var currentWarning: WarningType? + @State private var pendingWarnings: [WarningType] = [] + @State private var warningContinuation: CheckedContinuation? + @State private var swipeProgress: CGFloat = 0 + + var accentColor: Color { + app.selectedWalletToPayFrom == .lightning ? .purpleAccent : .brandAccent + } + + var canSwitchWallet: Bool { + if app.scannedOnchainInvoice != nil && app.scannedLightningInvoice != nil { + let amount = app.scannedOnchainInvoice?.amountSatoshis ?? 0 + return amount <= wallet.spendableOnchainBalanceSats && amount <= wallet.totalLightningSats + } + + return false + } /// Warning system private enum WarningType: String, CaseIterable { @@ -29,48 +47,32 @@ struct SendConfirmationView: View { var title: String { switch self { - case .minimumFee: - return t("wallet__send_dialog5_title") - default: - return t("common__are_you_sure") + case .minimumFee: return t("wallet__send_dialog5_title") + default: return t("common__are_you_sure") } } var message: String { switch self { - case .amount: - return t("wallet__send_dialog1") - case .balance: - return t("wallet__send_dialog2") - case .fee: - return t("wallet__send_dialog4") - case .feePercentage: - return t("wallet__send_dialog3") - case .minimumFee: - return t("wallet__send_dialog5_description") + case .amount: return t("wallet__send_dialog1") + case .balance: return t("wallet__send_dialog2") + case .fee: return t("wallet__send_dialog4") + case .feePercentage: return t("wallet__send_dialog3") + case .minimumFee: return t("wallet__send_dialog5_description") } } } - @State private var currentWarning: WarningType? - @State private var pendingWarnings: [WarningType] = [] - @State private var warningContinuation: CheckedContinuation? - private var canEditAmount: Bool { - guard app.selectedWalletToPayFrom == .lightning else { - return true - } - - guard let invoice = app.scannedLightningInvoice else { - return true - } + guard app.selectedWalletToPayFrom == .lightning else { return true } + guard let invoice = app.scannedLightningInvoice else { return true } return invoice.amountSatoshis == 0 } var body: some View { VStack(alignment: .leading, spacing: 0) { - SheetHeader(title: t("wallet__send_review"), showBackButton: true) + SheetHeader(title: t("wallet__send_review"), showBackButton: !navigationPath.isEmpty) VStack(alignment: .leading, spacing: 0) { if app.selectedWalletToPayFrom == .lightning, let invoice = app.scannedLightningInvoice { @@ -80,8 +82,6 @@ struct SendConfirmationView: View { testIdPrefix: "ReviewAmount", onTap: navigateToAmount ) - .padding(.bottom, 44) - lightningView(invoice) } else if app.selectedWalletToPayFrom == .onchain, let invoice = app.scannedOnchainInvoice { MoneyStack( sats: Int(wallet.sendAmountSats ?? invoice.amountSatoshis), @@ -89,35 +89,47 @@ struct SendConfirmationView: View { testIdPrefix: "ReviewAmount", onTap: navigateToAmount ) - .padding(.bottom, 44) - onchainView(invoice) } } .multilineTextAlignment(.leading) .frame(maxWidth: .infinity, alignment: .leading) + .padding(.bottom, 44) - CaptionMText(t("wallet__tags")) - .padding(.top, 16) - .padding(.bottom, 8) + if showDetails { + if app.selectedWalletToPayFrom == .onchain, let invoice = app.scannedOnchainInvoice { + onchainView(invoice) + } else if app.selectedWalletToPayFrom == .lightning, let invoice = app.scannedLightningInvoice { + lightningView(invoice) + } + } else { + Image("coin-stack-4") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: UIScreen.main.bounds.width * 0.8) + .frame(maxWidth: .infinity) + .padding(.bottom, 16) + .rotationEffect(.degrees(swipeProgress * 14)) + } - TagsListView( - tags: tagManager.selectedTagsArray, - icon: .close, - onAddTag: { - navigationPath.append(.tag) - }, - onTagDelete: { tag in - tagManager.removeTagFromSelection(tag) - }, - addButtonTestId: "TagsAddSend" - ) + if !UIScreen.main.isSmall || !showDetails { + CustomButton( + title: showDetails ? t("common__hide_details") : t("common__show_details"), + size: .small, + icon: Image(showDetails ? "eye-slash" : app.selectedWalletToPayFrom == .lightning ? "bolt-hollow" : "speed-normal") + .resizable() + .frame(width: 16, height: 16) + .foregroundColor(accentColor) + ) { + showDetails.toggle() + } + .frame(maxWidth: .infinity, alignment: .center) + .padding(.top, 16) + .accessibilityIdentifier("SendConfirmToggleDetails") + } - Spacer() + Spacer(minLength: 16) - SwipeButton( - title: t("wallet__send_swipe"), - accentColor: app.selectedWalletToPayFrom == .onchain ? .brandAccent : .purpleAccent - ) { + SwipeButton(title: t("wallet__send_swipe"), accentColor: accentColor, swipeProgress: $swipeProgress) { // Validate payment and show warnings if needed let warnings = await validatePayment() if !warnings.isEmpty { @@ -161,11 +173,22 @@ struct SendConfirmationView: View { await calculateTransactionFee() await calculateRoutingFee() } - .onChange(of: wallet.selectedFeeRateSatsPerVByte) { _ in + .onChange(of: wallet.selectedFeeRateSatsPerVByte) { Task { await calculateTransactionFee() } } + .onChange(of: app.selectedWalletToPayFrom) { + Task { + if app.selectedWalletToPayFrom == .lightning { + await MainActor.run { transactionFee = 0 } + await calculateRoutingFee() + } else { + await MainActor.run { routingFee = 0 } + await ensureOnChainStateAndRecalculateFee() + } + } + } .alert( t("security__bio_error_title"), isPresented: $showingBiometricError @@ -211,6 +234,197 @@ struct SendConfirmationView: View { } } + func onchainView(_ invoice: OnChainInvoice) -> some View { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .top, spacing: 16) { + SendSectionView(t("wallet__send_from")) { + NumberPadActionButton( + text: t("wallet__savings__title"), + imageName: canSwitchWallet ? "arrow-up-down" : nil, + color: app.selectedWalletToPayFrom == .lightning ? .purpleAccent : .brandAccent, + variant: canSwitchWallet ? .primary : .secondary, + disabled: !canSwitchWallet + ) { + if canSwitchWallet { + app.selectedWalletToPayFrom.toggle() + } + } + .accessibilityIdentifier("SendConfirmAssetButton") + } + + Button { + navigateToManual(with: invoice.address) + } label: { + SendSectionView(t("wallet__send_to")) { + BodySSBText(invoice.address.ellipsis(maxLength: 18)) + .lineLimit(1) + .truncationMode(.middle) + .frame(height: 28) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .buttonStyle(.plain) + .accessibilityIdentifier("ReviewUri") + } + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(alignment: .top, spacing: 16) { + Button(action: { + navigationPath.append(.feeRate) + }) { + SendSectionView(t("wallet__send_fee_and_speed")) { + HStack(spacing: 0) { + Image(wallet.selectedSpeed.iconName) + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(wallet.selectedSpeed.iconColor) + .frame(width: 16, height: 16) + .padding(.trailing, 4) + + if transactionFee > 0 { + let feeText = "\(wallet.selectedSpeed.title) (" + HStack(spacing: 0) { + BodySSBText(feeText) + MoneyText(sats: transactionFee, size: .bodySSB, symbol: true, symbolColor: .textPrimary) + BodySSBText(")") + } + + Image("pencil") + .foregroundColor(.textPrimary) + .frame(width: 12, height: 12) + .padding(.leading, 6) + } + } + } + } + + SendSectionView(t("wallet__send_confirming_in")) { + HStack(spacing: 0) { + Image("clock") + .foregroundColor(.brandAccent) + .frame(width: 16, height: 16) + .padding(.trailing, 4) + + BodySSBText( + TransactionSpeed.getFeeTierLocalized( + feeRate: UInt64(wallet.selectedFeeRateSatsPerVByte ?? 0), + feeEstimates: feeEstimatesManager.estimates, + variant: .range + ) + ) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + SendSectionView(t("wallet__tags")) { + TagsListView( + tags: tagManager.selectedTagsArray, + icon: .close, + onAddTag: { + navigationPath.append(.tag) + }, + onTagDelete: { tag in + tagManager.removeTagFromSelection(tag) + }, + addButtonTestId: "TagsAddSend" + ) + } + } + } + + func lightningView(_ invoice: LightningInvoice) -> some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + SendSectionView(t("wallet__send_from")) { + NumberPadActionButton( + text: t("wallet__spending__title"), + imageName: canSwitchWallet ? "arrow-up-down" : nil, + color: app.selectedWalletToPayFrom == .lightning ? .purpleAccent : .brandAccent, + variant: canSwitchWallet ? .primary : .secondary, + disabled: !canSwitchWallet + ) { + if canSwitchWallet { + app.selectedWalletToPayFrom.toggle() + } + } + .accessibilityIdentifier("SendConfirmAssetButton") + } + + Spacer(minLength: 16) + + Button { + navigateToManual(with: invoice.bolt11) + } label: { + SendSectionView(t("wallet__send_to")) { + BodySSBText(invoice.bolt11.ellipsis(maxLength: 18)) + .lineLimit(1) + .truncationMode(.middle) + .frame(height: 28) + } + } + .buttonStyle(.plain) + .accessibilityIdentifier("ReviewUri") + } + + HStack(alignment: .top, spacing: 16) { + SendSectionView(t("wallet__send_fee_and_speed")) { + HStack(spacing: 4) { + Image("bolt-hollow") + .foregroundColor(.purpleAccent) + .frame(width: 16, height: 16) + + if routingFee > 0 { + let feeText = "\(t("fee__instant__title")) (±" + HStack(spacing: 0) { + BodySSBText(feeText) + MoneyText(sats: routingFee, size: .bodySSB, symbol: true, symbolColor: .textPrimary) + BodySSBText(")") + } + } else { + BodySSBText(t("fee__instant__title")) + } + } + } + + SendSectionView(t("wallet__send_invoice_expiration")) { + HStack(spacing: 4) { + Image("timer-alt") + .foregroundColor(.purpleAccent) + .frame(width: 16, height: 16) + + BodySSBText(DateFormatterHelpers.formatInvoiceExpiryRelative( + timestampSeconds: invoice.timestampSeconds, + expirySeconds: invoice.expirySeconds + )) + } + } + } + + if let description = app.scannedLightningInvoice?.description, !description.isEmpty { + SendSectionView(t("wallet__note")) { + ScrollView(.horizontal, showsIndicators: false) { + BodySSBText(description) + .lineLimit(1) + .allowsTightening(false) + } + } + } + + SendSectionView(t("wallet__tags")) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(tagManager.selectedTagsArray, id: \.self) { tag in + Tag(tag, icon: .close, onDelete: { tagManager.removeTagFromSelection(tag) }) + } + AddTagButton(onPress: { navigationPath.append(.tag) }) + .accessibilityIdentifier("TagsAddSend") + } + } + } + } + } + private func waitForPinCheck() async throws -> Bool { return try await withCheckedThrowingContinuation { continuation in pinCheckContinuation = continuation @@ -395,168 +609,6 @@ struct SendConfirmationView: View { try? await CoreService.shared.activity.addPreActivityMetadata(preActivityMetadata) } - func onchainView(_ invoice: OnChainInvoice) -> some View { - VStack(alignment: .leading, spacing: 0) { - editableInvoiceSection( - title: t("wallet__send_to"), - value: invoice.address - ) - .padding(.bottom) - .frame(maxWidth: .infinity, alignment: .leading) - - Divider() - - HStack { - Button(action: { - navigationPath.append(.feeRate) - }) { - VStack(alignment: .leading, spacing: 8) { - CaptionMText(t("wallet__send_fee_and_speed")) - HStack(spacing: 0) { - Image(wallet.selectedSpeed.iconName) - .resizable() - .aspectRatio(contentMode: .fit) - .foregroundColor(wallet.selectedSpeed.iconColor) - .frame(width: 16, height: 16) - .padding(.trailing, 4) - - if transactionFee > 0 { - let feeText = "\(wallet.selectedSpeed.title) (" - HStack(spacing: 0) { - BodySSBText(feeText) - MoneyText(sats: transactionFee, size: .bodySSB, symbol: true, symbolColor: .textPrimary) - BodySSBText(")") - } - - Image("pencil") - .foregroundColor(.textPrimary) - .frame(width: 12, height: 12) - .padding(.leading, 6) - } - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } - - Spacer() - - VStack(alignment: .leading, spacing: 8) { - CaptionMText(t("wallet__send_confirming_in")) - HStack(spacing: 0) { - Image("clock") - .foregroundColor(.brandAccent) - .frame(width: 16, height: 16) - .padding(.trailing, 4) - - BodySSBText( - TransactionSpeed.getFeeTierLocalized( - feeRate: UInt64(wallet.selectedFeeRateSatsPerVByte ?? 0), - feeEstimates: feeEstimatesManager.estimates, - variant: .range - ) - ) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } - .padding(.vertical) - .frame(maxWidth: .infinity, alignment: .leading) - - Divider() - } - } - - func lightningView(_ invoice: LightningInvoice) -> some View { - VStack(alignment: .leading, spacing: 0) { - editableInvoiceSection( - title: t("wallet__send_invoice"), - value: invoice.bolt11 - ) - .padding(.bottom) - .frame(maxWidth: .infinity, alignment: .leading) - - Divider() - - HStack { - VStack(alignment: .leading, spacing: 0) { - CaptionMText(t("wallet__send_fee_and_speed")) - .padding(.bottom, 8) - - HStack(spacing: 0) { - Image("bolt-hollow") - .foregroundColor(.purpleAccent) - .frame(width: 16, height: 16) - .padding(.trailing, 4) - - if routingFee > 0 { - let feeText = "\(t("fee__instant__title")) (±" - HStack(spacing: 0) { - BodySSBText(feeText) - MoneyText(sats: routingFee, size: .bodySSB, symbol: true, symbolColor: .textPrimary) - BodySSBText(")") - } - } else { - BodySSBText(t("fee__instant__title")) - } - } - - Divider() - .padding(.top, 16) - } - .frame(maxWidth: .infinity, alignment: .leading) - - Spacer(minLength: 16) - - VStack(alignment: .leading, spacing: 0) { - CaptionMText(t("wallet__send_invoice_expiration")) - .padding(.bottom, 8) - - HStack(spacing: 0) { - Image("timer-alt") - .foregroundColor(.purpleAccent) - .frame(width: 16, height: 16) - .padding(.trailing, 4) - - // TODO: get actual expiration time from invoice - BodySSBText("10 minutes") - } - - Divider() - .padding(.top, 16) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - .padding(.top) - .frame(maxWidth: .infinity, alignment: .leading) - - if let description = app.scannedLightningInvoice?.description, !description.isEmpty { - VStack(alignment: .leading, spacing: 8) { - CaptionMText(t("wallet__note")) - BodySSBText(description) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.vertical, 16) - - Divider() - } - } - } - - private func editableInvoiceSection(title: String, value: String) -> some View { - Button { - navigateToManual(with: value) - } label: { - VStack(alignment: .leading, spacing: 8) { - CaptionMText(title) - BodySSBText(value.ellipsis(maxLength: 20)) - .lineLimit(1) - .truncationMode(.middle) - } - } - .buttonStyle(.plain) - .accessibilityIdentifier("ReviewUri") - } - private func navigateToManual(with value: String) { guard !value.isEmpty else { return } app.manualEntryInput = value @@ -586,6 +638,66 @@ struct SendConfirmationView: View { } } + /// Ensures fee rate and UTXO selection are set when user switches to on-chain, then recalculates fee. + private func ensureOnChainStateAndRecalculateFee() async { + guard app.selectedWalletToPayFrom == .onchain else { return } + guard let invoice = app.scannedOnchainInvoice else { return } + + if wallet.sendAmountSats == nil { + await MainActor.run { + wallet.sendAmountSats = invoice.amountSatoshis + } + } + + if wallet.selectedFeeRateSatsPerVByte == nil { + do { + try await wallet.setFeeRate(speed: settings.defaultTransactionSpeed) + } catch { + Logger.error("Failed to set fee rate when switching to on-chain: \(error)") + await MainActor.run { + app.selectedWalletToPayFrom = .lightning + app.toast(type: .error, title: t("other__try_again")) + } + return + } + } + + if settings.coinSelectionMethod == .manual { + if wallet.selectedUtxos == nil || wallet.selectedUtxos?.isEmpty == true { + do { + try await wallet.loadAvailableUtxos() + await MainActor.run { + navigationPath.append(.utxoSelection) + } + } catch { + Logger.error("Failed to load UTXOs when switching to on-chain: \(error)") + await MainActor.run { + app.selectedWalletToPayFrom = .lightning + app.toast(type: .error, title: t("other__try_again")) + } + } + return + } + } else { + do { + try await wallet.setUtxoSelection(coinSelectionAlgorythm: settings.coinSelectionAlgorithm) + } catch { + Logger.error("Failed to set UTXO selection when switching to on-chain: \(error)") + await MainActor.run { + app.selectedWalletToPayFrom = .lightning + app.toast( + type: .error, + title: t("other__try_again"), + description: error.localizedDescription + ) + } + return + } + } + + await calculateTransactionFee() + } + private func calculateTransactionFee() async { guard app.selectedWalletToPayFrom == .onchain else { return diff --git a/Bitkit/Views/Wallets/Send/SendUtxoSelectionView.swift b/Bitkit/Views/Wallets/Send/SendUtxoSelectionView.swift index ab3f7ef8f..4b1f3d020 100644 --- a/Bitkit/Views/Wallets/Send/SendUtxoSelectionView.swift +++ b/Bitkit/Views/Wallets/Send/SendUtxoSelectionView.swift @@ -26,8 +26,10 @@ struct SendUtxoSelectionView: View { VStack(spacing: 0) { SheetHeader(title: t("wallet__selection_title"), showBackButton: true) - ScrollView { + ScrollView(showsIndicators: false) { LazyVStack(spacing: 0) { + Divider() + ForEach(Array(wallet.availableUtxos.enumerated()), id: \.element.outpoint.txid) { _, utxo in UtxoRowView( utxo: utxo, @@ -40,9 +42,10 @@ struct SendUtxoSelectionView: View { selectedUtxos.remove(utxo.outpoint.txid) } } + + Divider() } } - .padding(.top, 16) } Spacer() @@ -50,33 +53,27 @@ struct SendUtxoSelectionView: View { // Bottom summary VStack(spacing: 8) { HStack { - BodyMText(t("wallet__selection_total_required").uppercased(), textColor: .textSecondary) + CaptionMText(t("wallet__selection_total_required")) Spacer() BodyMSBText("\(formatSats(totalRequiredSats))", textColor: .textPrimary) } - .padding(.top, 16) + .frame(height: 40) Divider() HStack { - BodyMText(t("wallet__selection_total_selected").uppercased(), textColor: .textSecondary) + CaptionMText(t("wallet__selection_total_selected")) Spacer() BodyMSBText("\(formatSats(totalSelectedSats))", textColor: totalSelectedSats >= totalRequiredSats ? .greenAccent : .redAccent) } + .frame(height: 40) } .padding(.bottom, 16) CustomButton(title: t("common__continue"), isDisabled: selectedUtxos.isEmpty || totalSelectedSats < totalRequiredSats) { - do { - wallet.selectedUtxos = wallet.availableUtxos.filter { selectedUtxos.contains($0.outpoint.txid) } - - navigationPath.append(.confirm) - } catch { - Logger.error(error, context: "Failed to set fee rate") - app.toast(type: .error, title: "Send Error", description: error.localizedDescription) - } + wallet.selectedUtxos = wallet.availableUtxos.filter { selectedUtxos.contains($0.outpoint.txid) } + navigationPath.append(.confirm) } - .padding(.bottom, 16) } .padding(.horizontal, 16) .navigationBarHidden(true) @@ -139,7 +136,7 @@ struct UtxoRowView: View { VStack(alignment: .leading, spacing: 4) { BodyMSBText("₿ \(formatBtcAmount(utxo.valueSats))", textColor: .textPrimary) .lineLimit(1) - BodySText("\(currency.symbol) \(formatUsdAmount(utxo.valueSats))", textColor: .textSecondary) + CaptionBText("\(currency.symbol) \(formatUsdAmount(utxo.valueSats))", textColor: .textSecondary) .lineLimit(1) } .fixedSize(horizontal: true, vertical: false) @@ -167,7 +164,6 @@ struct UtxoRowView: View { .padding(.trailing, 2) .fixedSize() } - Divider() } .padding(.vertical, 16) }