diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 17da0c51..6498a3b2 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() + @State 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) + .environment(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 00000000..570b584b --- /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 00000000..bfab15e5 Binary files /dev/null and b/Bitkit/Assets.xcassets/icons/camera.imageset/camera.pdf differ 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 00000000..969759f9 --- /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 00000000..3690f6ae Binary files /dev/null and b/Bitkit/Assets.xcassets/icons/eye-slash.imageset/eye-slash.pdf differ diff --git a/Bitkit/Components/ActivityIndicator.swift b/Bitkit/Components/ActivityIndicator.swift index b7ab3340..a82f2a65 100644 --- a/Bitkit/Components/ActivityIndicator.swift +++ b/Bitkit/Components/ActivityIndicator.swift @@ -2,23 +2,31 @@ import SwiftUI struct ActivityIndicator: View { let size: CGFloat + let theme: Theme + + enum Theme { + case light + case dark + } @State private var isRotating = false @State private var opacity: Double = 0 - init(size: CGFloat = 32) { + init(size: CGFloat = 32, theme: Theme = .light) { self.size = size + self.theme = theme } var body: some View { let strokeWidth = size / 12 + let color = theme == .light ? Color.white : Color.black ZStack { Circle() .trim(from: 0.1, to: 0.94) .stroke( AngularGradient( - gradient: Gradient(colors: [.black, .white]), + gradient: Gradient(colors: [.clear, color]), center: .center, startAngle: .degrees(0), endAngle: .degrees(360) diff --git a/Bitkit/Components/Button/PrimaryButtonView.swift b/Bitkit/Components/Button/PrimaryButtonView.swift index b8939370..4107f929 100644 --- a/Bitkit/Components/Button/PrimaryButtonView.swift +++ b/Bitkit/Components/Button/PrimaryButtonView.swift @@ -34,6 +34,7 @@ struct PrimaryButtonView: View { .background(backgroundGradient) .cornerRadius(64) .shadow(color: shadowColor, radius: 0, x: 0, y: -1) + .shadow(color: Color.black.opacity(0.32), radius: 4, x: 0, y: 2) .opacity(isDisabled ? 0.32 : 1.0) .contentShape(Rectangle()) } diff --git a/Bitkit/Components/Scanner.swift b/Bitkit/Components/Scanner.swift index 8b6b5525..08adf7c9 100644 --- a/Bitkit/Components/Scanner.swift +++ b/Bitkit/Components/Scanner.swift @@ -63,6 +63,8 @@ private struct ScannerCornerButtons: View { // MARK: - Scanner Component struct Scanner: View { + @Environment(CameraManager.self) private var cameraManager + let onScan: (String) async -> Void let onImageSelection: (PhotosPickerItem?) async -> Void @@ -70,20 +72,53 @@ struct Scanner: View { 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/Components/SendSectionView.swift b/Bitkit/Components/SendSectionView.swift new file mode 100644 index 00000000..98bb0012 --- /dev/null +++ b/Bitkit/Components/SendSectionView.swift @@ -0,0 +1,25 @@ +import SwiftUI + +/// A section with a caption label, content, and a divider below. Used for form-style rows (e.g. "Send from", "Send to"). +struct SendSectionView: 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 3d39e8a0..3078cf75 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/MainNavView.swift b/Bitkit/MainNavView.swift index 1829d883..1ed8ade7 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -2,6 +2,7 @@ import SwiftUI struct MainNavView: View { @EnvironmentObject private var app: AppViewModel + @Environment(CameraManager.self) private var 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 00000000..f9887736 --- /dev/null +++ b/Bitkit/Managers/CameraManager.swift @@ -0,0 +1,45 @@ +import AVFoundation +import SwiftUI + +@MainActor +@Observable +final class CameraManager { + static let shared = CameraManager() + + var hasPermission: Bool = false + + init() { + refreshPermission() + } + + func refreshPermission() { + hasPermission = AVCaptureDevice.authorizationStatus(for: .video) == .authorized + } + + /// 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 + Task { @MainActor in + 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) { + UIApplication.shared.open(url) + } + case .authorized: + hasPermission = true + @unknown default: + break + } + } +} diff --git a/Bitkit/Models/TransactionSpeed.swift b/Bitkit/Models/TransactionSpeed.swift index 115b831e..2cde8858 100644 --- a/Bitkit/Models/TransactionSpeed.swift +++ b/Bitkit/Models/TransactionSpeed.swift @@ -5,6 +5,7 @@ import SwiftUI // MARK: - Core Transaction Speed Enum public enum TransactionSpeed: Equatable, Hashable, RawRepresentable { + case instant case fast case normal case slow @@ -13,7 +14,9 @@ public enum TransactionSpeed: Equatable, Hashable, RawRepresentable { // MARK: - RawRepresentable Implementation public init(rawValue: String) { - if rawValue == "fast" { + if rawValue == "instant" { + self = .instant + } else if rawValue == "fast" { self = .fast } else if rawValue == "normal" { self = .normal @@ -31,6 +34,7 @@ public enum TransactionSpeed: Equatable, Hashable, RawRepresentable { public var rawValue: String { switch self { + case .instant: return "instant" case .fast: return "fast" case .normal: return "normal" case .slow: return "slow" @@ -45,6 +49,7 @@ public extension TransactionSpeed { /// Component used to build fee localization keys (e.g. "fee__fast__title", "fee__fast__longTitle"). var feeKeyComponent: String { switch self { + case .instant: return "instant" case .fast: return "fast" case .normal: return "normal" case .slow: return "slow" @@ -72,10 +77,6 @@ public extension TransactionSpeed { t("fee__\(feeKeyComponent)__range") } - var longRange: String { - t("fee__\(feeKeyComponent)__longRange") - } - var isCustom: Bool { if case .custom = self { return true } return false @@ -88,6 +89,7 @@ public extension TransactionSpeed { var iconName: String { switch self { + case .instant: return "speed-fast" case .fast: return "speed-fast" case .normal: return "speed-normal" case .slow: return "speed-slow" @@ -97,6 +99,7 @@ public extension TransactionSpeed { var iconColor: Color { switch self { + case .instant: return .purpleAccent case .fast: return .brandAccent case .normal: return .brandAccent case .slow: return .brandAccent @@ -115,7 +118,6 @@ public extension TransactionSpeed { case description case shortDescription case range - case longRange } /// Returns the fee rate in satoshis per virtual byte for this speed @@ -123,6 +125,7 @@ public extension TransactionSpeed { /// - Returns: Fee rate in sat/vB func getFeeRate(from feeRates: FeeRates) -> UInt32 { switch self { + case .instant: return feeRates.fast case .fast: return feeRates.fast case .normal: return feeRates.mid case .slow: return feeRates.slow diff --git a/Bitkit/Models/WalletType.swift b/Bitkit/Models/WalletType.swift index e33a7965..f9842e47 100644 --- a/Bitkit/Models/WalletType.swift +++ b/Bitkit/Models/WalletType.swift @@ -1,24 +1,18 @@ -import Foundation - enum WalletType { case onchain case lightning var title: String { switch self { - case .onchain: - return t("lightning__savings").uppercased() - case .lightning: - return t("lightning__spending").uppercased() + case .onchain: t("lightning__savings") + case .lightning: t("lightning__spending") } } var imageAsset: String { switch self { - case .onchain: - return "btc" - case .lightning: - return "ln" + case .onchain: "btc" + case .lightning: "ln" } } diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index a508858d..264e98eb 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -83,41 +83,37 @@ "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"; "fee__instant__shortDescription" = "±2s"; -"fee__instant__range" = "±2-10 seconds"; +"fee__instant__range" = "±1-5 seconds"; "fee__fast__title" = "Fast"; "fee__fast__longTitle" = "Fast (more expensive)"; "fee__fast__description" = "±10 minutes"; "fee__fast__shortDescription" = "±10m"; "fee__fast__range" = "±10-20 minutes"; -"fee__fast__longRange" = "± 10-20 minutes"; "fee__normal__title" = "Normal"; "fee__normal__longTitle" = "Normal"; "fee__normal__description" = "±20 minutes"; "fee__normal__shortDescription" = "±20m"; "fee__normal__range" = "±20-60 minutes"; -"fee__normal__longRange" = "± 20-60 minutes"; "fee__slow__title" = "Slow"; "fee__slow__longTitle" = "Slow (cheaper)"; "fee__slow__description" = "±1 hours"; "fee__slow__shortDescription" = "±1h"; "fee__slow__range" = "±1-2 hours"; -"fee__slow__longRange" = "± 1-2 hours"; "fee__minimum__title" = "Minimum"; "fee__minimum__longTitle" = "Minimum"; "fee__minimum__description" = "+2 hours"; "fee__minimum__shortDescription" = "+2h"; "fee__minimum__range" = "+2 hours"; -"fee__minimum__longRange" = "+ 2 hours"; "fee__custom__title" = "Custom"; "fee__custom__longTitle" = "Custom"; "fee__custom__description" = "Depends on the fee"; "fee__custom__shortDescription" = "Depends on the fee"; "fee__custom__range" = "Depends on the fee"; -"fee__custom__longRange" = "Depends on fee"; "lightning__transfer_intro__title" = "Spending\nBalance"; "lightning__transfer_intro__text" = "Fund your spending balance to enjoy instant and cheap transactions with friends, family, and merchants."; "lightning__transfer_intro__button" = "Get Started"; @@ -399,9 +395,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"; @@ -976,6 +972,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 +982,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 +990,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 ded79459..3e24d1d3 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/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 808c01ed..b88973a3 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -26,6 +26,8 @@ class WalletViewModel: ObservableObject { @Published var selectedUtxos: [SpendableUtxo]? @Published var availableUtxos: [SpendableUtxo] = [] @Published var isMaxAmountSend: Bool = false + /// Cached Lightning routing fee estimate for the active send (confirmation, fee picker instant row). + @Published var routingFeeEstimateSats: UInt64 = 0 /// LNURL withdraw flow @Published var lnurlWithdrawAmount: UInt64? @@ -581,6 +583,18 @@ class WalletViewModel: ObservableObject { return try await lightningService.estimateRoutingFees(bolt11: bolt11, amountSats: amountSats) } + /// Refreshes `routingFeeEstimateSats` for the send UI. + func refreshRoutingFeeEstimate(bolt11: String, amountSats: UInt64?) async { + do { + let fee = try await estimateRoutingFees(bolt11: bolt11, amountSats: amountSats) + routingFeeEstimateSats = fee + Logger.info("Estimated routing fees: \(fee) sat") + } catch { + Logger.error("Failed to calculate routing fees: \(error)", context: "WalletViewModel") + routingFeeEstimateSats = 0 + } + } + /// Sends a lightning payment with an optional timeout. /// If the payment does not complete within `timeoutSeconds`, throws `PaymentTimeoutError.timedOut`. /// The payment continues in the background; caller should navigate to pending screen on timeout. @@ -923,6 +937,7 @@ class WalletViewModel: ObservableObject { availableUtxos = [] selectedSpeed = speed isMaxAmountSend = false + routingFeeEstimateSats = 0 } private func handleChannelClosed(channelId: LDKNode.ChannelId, reason: LDKNode.ClosureReason?) async { diff --git a/Bitkit/Views/Settings/TransactionSpeed/TransactionSpeedSettingsView.swift b/Bitkit/Views/Settings/TransactionSpeed/TransactionSpeedSettingsView.swift index e95b42af..6e7b1553 100644 --- a/Bitkit/Views/Settings/TransactionSpeed/TransactionSpeedSettingsView.swift +++ b/Bitkit/Views/Settings/TransactionSpeed/TransactionSpeedSettingsView.swift @@ -8,7 +8,7 @@ struct TransactionSpeedSettingsRow: View { var rangeOverride: String? private var rangeText: String { - rangeOverride ?? speed.longRange + rangeOverride ?? speed.range } var body: some View { @@ -56,7 +56,7 @@ struct TransactionSpeedSettingsView: View { /// When custom default fee rate is set, returns the tier-based range description (e.g. "± 10-20 minutes"). private func customSpeedRange() -> String? { guard case let .custom(rate) = settings.defaultTransactionSpeed else { return nil } - return TransactionSpeed.getFeeTierLocalized(feeRate: UInt64(rate), feeEstimates: feeEstimatesManager.estimates, variant: .longRange) + return TransactionSpeed.getFeeTierLocalized(feeRate: UInt64(rate), feeEstimates: feeEstimatesManager.estimates, variant: .range) } var body: some View { diff --git a/Bitkit/Views/Sheets/Sheet.swift b/Bitkit/Views/Sheets/Sheet.swift index e988cd44..ee32d67d 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 9e7e8ddf..005cac21 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 7ff3eda8..f189e8b2 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 e6da1d45..cc494af6 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -11,13 +11,51 @@ 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 @State private var biometricErrorMessage = "" @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 = wallet.sendAmountSats ?? app.scannedOnchainInvoice?.amountSatoshis ?? 0 + guard amount >= UInt64(Env.dustLimit) else { return false } + return amount <= wallet.spendableOnchainBalanceSats && amount <= wallet.maxSendLightningSats + } + + return false + } + + /// `.instant` is only valid when paying from Lightning; align `selectedSpeed` with the current sat/vB on savings. + private func reconcileInstantSpeedWhenSwitchingToOnChain() async { + guard wallet.selectedSpeed == .instant else { return } + + await MainActor.run { + wallet.selectedSpeed = settings.defaultTransactionSpeed + } + } + + /// BIP21 flow can land on confirm with `sendAmountSats` unset; set it from the scanned invoices. + @MainActor + private func ensureSendAmountFromScannedInvoicesIfNeeded() { + guard wallet.sendAmountSats == nil || wallet.sendAmountSats == 0 else { return } + if let invoice = app.scannedOnchainInvoice, invoice.amountSatoshis > 0 { + wallet.sendAmountSats = invoice.amountSatoshis + } else if let lightning = app.scannedLightningInvoice, lightning.amountSatoshis > 0 { + wallet.sendAmountSats = lightning.amountSatoshis + } + } /// Warning system private enum WarningType: String, CaseIterable { @@ -29,48 +67,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 +102,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 +109,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 { @@ -158,6 +190,7 @@ struct SendConfirmationView: View { .sheetBackground() .frame(maxWidth: .infinity, maxHeight: .infinity) .task { + ensureSendAmountFromScannedInvoicesIfNeeded() await calculateTransactionFee() await calculateRoutingFee() } @@ -166,6 +199,15 @@ struct SendConfirmationView: View { await calculateTransactionFee() } } + .onChange(of: app.selectedWalletToPayFrom) { + Task { + if app.selectedWalletToPayFrom == .lightning { + await MainActor.run { transactionFee = 0 } + } else { + await onSwitchToOnchainWallet() + } + } + } .alert( t("security__bio_error_title"), isPresented: $showingBiometricError @@ -211,6 +253,216 @@ 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) { + Button(action: { + if canSwitchWallet { + navigationPath.append(.feeRate) + } + }) { + SendSectionView(t("wallet__send_fee_and_speed")) { + HStack(spacing: 0) { + Image("bolt-hollow") + .foregroundColor(.purpleAccent) + .frame(width: 16, height: 16) + .padding(.trailing, 4) + + if wallet.routingFeeEstimateSats > 0 { + let feeText = "\(t("fee__instant__title")) (±" + HStack(spacing: 0) { + BodySSBText(feeText) + MoneyText( + sats: Int(wallet.routingFeeEstimateSats), + size: .bodySSB, + symbol: true, + symbolColor: .textPrimary + ) + BodySSBText(")") + } + } else { + BodySSBText(t("fee__instant__title")) + } + + if canSwitchWallet { + Image("pencil") + .foregroundColor(.textPrimary) + .frame(width: 12, height: 12) + .padding(.leading, 6) + } + } + } + } + + 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 +647,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 +676,61 @@ struct SendConfirmationView: View { } } + /// After the user chooses savings, prepares on-chain send (amount, fee rate, UTXOs) and refreshes the shown fee. + private func onSwitchToOnchainWallet() async { + guard app.selectedWalletToPayFrom == .onchain else { return } + + await reconcileInstantSpeedWhenSwitchingToOnChain() + + 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 @@ -641,27 +786,17 @@ struct SendConfirmationView: View { } } + @MainActor private func calculateRoutingFee() async { - guard app.selectedWalletToPayFrom == .lightning else { - return - } - guard let bolt11 = app.scannedLightningInvoice?.bolt11 else { + wallet.routingFeeEstimateSats = 0 return } - do { - let fee = try await wallet.estimateRoutingFees(bolt11: bolt11, amountSats: wallet.sendAmountSats) - await MainActor.run { - Logger.info("Estimated routing fees: \(fee) sat") - routingFee = Int(fee) - } - } catch { - Logger.error("Failed to calculate routing fees: \(error)") - await MainActor.run { - Logger.error("Failed to calculate routing fees: \(error)") - routingFee = 0 - } + if canSwitchWallet || app.selectedWalletToPayFrom == .lightning { + await wallet.refreshRoutingFeeEstimate(bolt11: bolt11, amountSats: wallet.sendAmountSats) + } else { + wallet.routingFeeEstimateSats = 0 } } } diff --git a/Bitkit/Views/Wallets/Send/SendFeeCustom.swift b/Bitkit/Views/Wallets/Send/SendFeeCustom.swift index 0fb39084..dc037b28 100644 --- a/Bitkit/Views/Wallets/Send/SendFeeCustom.swift +++ b/Bitkit/Views/Wallets/Send/SendFeeCustom.swift @@ -3,6 +3,7 @@ import SwiftUI struct SendFeeCustom: View { @EnvironmentObject var app: AppViewModel @EnvironmentObject var currency: CurrencyViewModel + @EnvironmentObject var feeEstimatesManager: FeeEstimatesManager @EnvironmentObject var settings: SettingsViewModel @EnvironmentObject var wallet: WalletViewModel @@ -66,7 +67,7 @@ struct SendFeeCustom: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .task { await loadFeeLimits() - await initializeFromCurrentFee() + await initializeFee() } } @@ -76,8 +77,16 @@ struct SendFeeCustom: View { maxFee = limits.maxFee } - private func initializeFromCurrentFee() async { - feeRate = wallet.selectedFeeRateSatsPerVByte ?? 0 + private func initializeFee() async { + if case let .custom(rate) = wallet.selectedSpeed { + feeRate = rate + } else if case let .custom(rate) = settings.defaultTransactionSpeed { + feeRate = rate + } else if let estimates = feeEstimatesManager.estimates { + feeRate = estimates.slow + } else { + feeRate = 1 + } // Calculate the initial fee await calculateTransactionFee() @@ -177,7 +186,7 @@ struct SendFeeCustom: View { Task { do { try await wallet.setFeeRate(speed: .custom(satsPerVByte: feeRate)) - // Go back to fee selection + app.selectedWalletToPayFrom = .onchain navigationPath.removeLast() } catch { Logger.error("Failed to set custom fee rate: \(error)") diff --git a/Bitkit/Views/Wallets/Send/SendFeeRate.swift b/Bitkit/Views/Wallets/Send/SendFeeRate.swift index a4a081c7..fb223212 100644 --- a/Bitkit/Views/Wallets/Send/SendFeeRate.swift +++ b/Bitkit/Views/Wallets/Send/SendFeeRate.swift @@ -12,9 +12,20 @@ struct SendFeeRate: View { @State private var transactionFees: [TransactionSpeed: UInt64] = [:] + /// Both on-chain and Lightning options exist and the user can pay from either (BIP21 / unified invoice). + private var canSwitchWallet: Bool { + if app.scannedOnchainInvoice != nil, app.scannedLightningInvoice != nil { + let amount = wallet.sendAmountSats ?? app.scannedOnchainInvoice?.amountSatoshis ?? 0 + guard amount >= UInt64(Env.dustLimit) else { return false } + return amount <= wallet.spendableOnchainBalanceSats && amount <= wallet.maxSendLightningSats + } + return false + } + private var currentCustomFeeRate: UInt32 { - if let rate = wallet.selectedFeeRateSatsPerVByte { return rate } + if case let .custom(rate) = wallet.selectedSpeed { return rate } if case let .custom(rate) = settings.defaultTransactionSpeed { return rate } + if let estimates = feeEstimatesManager.estimates { return estimates.slow } return 1 } @@ -23,20 +34,25 @@ struct SendFeeRate: View { } private func isDisabled(for speed: TransactionSpeed) -> Bool { - let fee = getFee(for: speed) guard let amount = wallet.sendAmountSats else { return true } - // Disable if not enough balance and not already selected - return wallet.totalBalanceSats < amount + fee && wallet.selectedSpeed != speed + + let fee = getFee(for: speed) + return wallet.totalOnchainSats < amount + fee && wallet.selectedSpeed != speed } private func selectFee(_ speed: TransactionSpeed) { wallet.selectedSpeed = speed - Task { + Task { @MainActor in do { - try await wallet.setFeeRate(speed: speed) - // Go back to confirmation screen - navigationPath.removeLast() + if speed == .instant { + app.selectedWalletToPayFrom = .lightning + navigationPath.removeLast() + } else { + try await wallet.setFeeRate(speed: speed) + app.selectedWalletToPayFrom = .onchain + navigationPath.removeLast() + } } catch { Logger.error("Error setting fee rate: \(error)", context: "SendFeeRate") } @@ -104,6 +120,17 @@ struct SendFeeRate: View { ScrollView(showsIndicators: false) { VStack(spacing: 0) { + if canSwitchWallet { + FeeItem( + speed: .instant, + amount: wallet.routingFeeEstimateSats, + isSelected: wallet.selectedSpeed == .instant, + isDisabled: false + ) { + selectFee(.instant) + } + } + FeeItem( speed: .fast, amount: getFee(for: .fast), @@ -154,7 +181,10 @@ struct SendFeeRate: View { .navigationBarHidden(true) .sheetBackground() .frame(maxWidth: .infinity, maxHeight: .infinity) - .task { + .task { @MainActor in + if app.selectedWalletToPayFrom == .lightning, canSwitchWallet { + wallet.selectedSpeed = .instant + } await loadFeeEstimates() } .onChange(of: wallet.selectedFeeRateSatsPerVByte) { diff --git a/Bitkit/Views/Wallets/Send/SendUtxoSelectionView.swift b/Bitkit/Views/Wallets/Send/SendUtxoSelectionView.swift index 0f7a6a95..4b1f3d02 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,19 +53,20 @@ 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) @@ -70,7 +74,6 @@ struct SendUtxoSelectionView: View { wallet.selectedUtxos = wallet.availableUtxos.filter { selectedUtxos.contains($0.outpoint.txid) } navigationPath.append(.confirm) } - .padding(.bottom, 16) } .padding(.horizontal, 16) .navigationBarHidden(true) @@ -133,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) @@ -161,7 +164,6 @@ struct UtxoRowView: View { .padding(.trailing, 2) .fixedSize() } - Divider() } .padding(.vertical, 16) }