diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 4b0268ce..b7641fdc 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 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 b23222d0..3359022f 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/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 f3f5725e..b7f1ed3e 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 00000000..3ff59b7b --- /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 a508858d..24c33894 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"; @@ -399,9 +400,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 +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 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/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 03d5f3a5..ebdadd03 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 1bfe9ae0..314b0ab1 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 8853cd29..51854984 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 ab3f7ef8..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,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) }