diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 5a953ed8..ff15e3a2 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -7,7 +7,7 @@ "location" : "https://github.com/synonymdev/bitkit-core", "state" : { "branch" : "master", - "revision" : "539c48daa059bb9bb46bb00f5eb8e227021020d0" + "revision" : "31ebadcbc1fcbfcdd6e1f66e28b6208bc64a55f8" } }, { diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 032c2f93..02344467 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -81,6 +81,7 @@ struct AppScene: View { if UserDefaults.standard.bool(forKey: "pinOnLaunch") && settings.pinEnabled { isPinVerified = false } + SweepViewModel.checkAndPromptForSweepableFunds(sheets: sheets) } } .environmentObject(app) @@ -142,28 +143,49 @@ struct AppScene: View { @ViewBuilder private var migrationLoadingContent: some View { - VStack(spacing: 24) { - Spacer() - - ProgressView() - .scaleEffect(1.5) - .tint(.white) - - VStack(spacing: 8) { - Text("Updating Wallet") - .font(.system(size: 24, weight: .semibold)) - .foregroundColor(.white) - - Text("Please wait while we update the app...") - .font(.system(size: 16)) - .foregroundColor(.white.opacity(0.7)) - .multilineTextAlignment(.center) - } + VStack(spacing: 0) { + NavigationBar(title: t("migration__title"), showBackButton: false, showMenuButton: false) + + VStack(spacing: 0) { + VStack { + Spacer() + + Image("wallet") + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + .aspectRatio(1, contentMode: .fit) + + Spacer() + } + .frame(maxWidth: .infinity) + .frame(maxHeight: .infinity) + .layoutPriority(1) + + VStack(alignment: .leading, spacing: 14) { + DisplayText(t("migration__headline")) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + BodyMText(t("migration__description")) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + } - Spacer() + ActivityIndicator(size: 32) + .padding(.top, 32) + } + .padding(.horizontal, 16) } .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color.black) + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + .background(Color.customBlack) + .onAppear { + UIApplication.shared.isIdleTimerDisabled = true + } + .onDisappear { + UIApplication.shared.isIdleTimerDisabled = false + } } @ViewBuilder diff --git a/Bitkit/Assets.xcassets/Illustrations/magnifying-glass-illustration.imageset/Contents.json b/Bitkit/Assets.xcassets/Illustrations/magnifying-glass-illustration.imageset/Contents.json new file mode 100644 index 00000000..f1537848 --- /dev/null +++ b/Bitkit/Assets.xcassets/Illustrations/magnifying-glass-illustration.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "magnifying glass 1.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "magnifying glass 1@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "magnifying glass 1@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Bitkit/Assets.xcassets/Illustrations/magnifying-glass-illustration.imageset/magnifying glass 1.png b/Bitkit/Assets.xcassets/Illustrations/magnifying-glass-illustration.imageset/magnifying glass 1.png new file mode 100644 index 00000000..27ab88b5 Binary files /dev/null and b/Bitkit/Assets.xcassets/Illustrations/magnifying-glass-illustration.imageset/magnifying glass 1.png differ diff --git a/Bitkit/Assets.xcassets/Illustrations/magnifying-glass-illustration.imageset/magnifying glass 1@2x.png b/Bitkit/Assets.xcassets/Illustrations/magnifying-glass-illustration.imageset/magnifying glass 1@2x.png new file mode 100644 index 00000000..6669f9b9 Binary files /dev/null and b/Bitkit/Assets.xcassets/Illustrations/magnifying-glass-illustration.imageset/magnifying glass 1@2x.png differ diff --git a/Bitkit/Assets.xcassets/Illustrations/magnifying-glass-illustration.imageset/magnifying glass 1@3x.png b/Bitkit/Assets.xcassets/Illustrations/magnifying-glass-illustration.imageset/magnifying glass 1@3x.png new file mode 100644 index 00000000..6fa42150 Binary files /dev/null and b/Bitkit/Assets.xcassets/Illustrations/magnifying-glass-illustration.imageset/magnifying glass 1@3x.png differ diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 5320ecf9..f88b0baf 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -10,6 +10,8 @@ struct MainNavView: View { @EnvironmentObject private var wallet: WalletViewModel @Environment(\.scenePhase) var scenePhase + @StateObject private var sweepViewModel = SweepViewModel() + @State private var showClipboardAlert = false @State private var clipboardUri: String? @@ -154,6 +156,14 @@ struct MainNavView: View { ) { config in ForceTransferSheet(config: config) } + .sheet( + item: $sheets.sweepPromptSheetItem, + onDismiss: { + sheets.hideSheet() + } + ) { + config in SweepPromptSheet(config: config) + } .accentColor(.white) .overlay { TabBar() @@ -386,6 +396,11 @@ struct MainNavView: View { case .electrumSettings: ElectrumSettingsScreen() case .rgsSettings: RgsSettingsScreen() case .addressViewer: AddressViewer() + case .sweep: SweepSettingsView().environmentObject(sweepViewModel) + case .sweepConfirm: SweepConfirmView().environmentObject(sweepViewModel) + case .sweepFeeRate: SweepFeeRateView().environmentObject(sweepViewModel) + case .sweepFeeCustom: SweepFeeCustomView().environmentObject(sweepViewModel) + case let .sweepSuccess(txid): SweepSuccessView(txid: txid).environmentObject(sweepViewModel) // Dev settings case .blocktankRegtest: BlocktankRegtestView() diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 57233572..78a63393 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -53,6 +53,7 @@ "common__delete" = "Delete"; "common__delete_yes" = "Yes, Delete"; "common__ok" = "OK"; +"common__total" = "Total"; "common__ok_random" = "Awesome!\nNice!\nCool!\nGreat!\nFantastic!\nSweet!\nExcellent!\nTerrific!"; "common__reset" = "Reset"; "common__retry" = "Retry"; @@ -725,7 +726,39 @@ "settings__adv__pp_contacts" = "Pay to/from contacts"; "settings__adv__pp_contacts_switch" = "Enable payments with contacts*"; "settings__adv__address_viewer" = "Address Viewer"; +"settings__adv__sweep_funds" = "Sweep Funds"; "settings__adv__rescan" = "Rescan Addresses"; +"sweep__title" = "Sweep Funds"; +"sweep__found_title" = "Found Funds"; +"sweep__loading_description" = "Please wait while Bitkit looks for funds in unsupported addresses (Legacy, Nested SegWit, and Taproot)."; +"sweep__looking_for_funds" = "LOOKING FOR FUNDS..."; +"sweep__found_description" = "Bitkit found funds in unsupported addresses (Legacy, Nested SegWit, and Taproot)."; +"sweep__funds_found" = "FUNDS FOUND"; +"sweep__sweep_to_wallet" = "Sweep To Wallet"; +"sweep__no_funds_title" = "No Funds To Sweep"; +"sweep__no_funds_description" = "Bitkit checked unsupported address types and found no funds to sweep."; +"sweep__error_title" = "Error"; +"sweep__error_destination_address" = "Failed to get destination address"; +"sweep__error_fee_rate_not_set" = "Fee rate not set"; +"sweep__confirm_title" = "Confirm Sweep"; +"sweep__amount" = "Amount to Receive"; +"sweep__destination" = "Destination"; +"sweep__loading_address" = "Getting address..."; +"sweep__preparing" = "Preparing transaction..."; +"sweep__broadcasting" = "Broadcasting..."; +"sweep__swipe" = "Swipe to Sweep"; +"sweep__fee_total" = "{fee} sats total fee"; +"sweep__complete_title" = "Sweep Complete"; +"sweep__complete_description" = "Your funds have been swept and will be added to your wallet balance."; +"sweep__wallet_overview" = "Wallet Overview"; +"sweep__view_details" = "View Details"; +"sweep__prompt_title" = "Sweep Funds"; +"sweep__prompt_headline" = "SWEEP OLD\nBITKIT FUNDS"; +"sweep__prompt_description" = "Bitkit found funds on unsupported Bitcoin address types. Sweep to move the funds to your new wallet balance."; +"sweep__prompt_sweep" = "Sweep"; +"migration__title" = "Wallet Migration"; +"migration__headline" = "MIGRATING\nWALLET"; +"migration__description" = "Please wait while your old wallet data migrates to this new Bitkit version..."; "settings__adv__suggestions_reset" = "Reset Suggestions"; "settings__adv__reset_title" = "Reset Suggestions?"; "settings__adv__reset_desc" = "Are you sure you want to reset the suggestions? They will reappear in case you have removed them from your Bitkit wallet overview."; diff --git a/Bitkit/Utilities/BiometricAuth.swift b/Bitkit/Utilities/BiometricAuth.swift new file mode 100644 index 00000000..4fed181d --- /dev/null +++ b/Bitkit/Utilities/BiometricAuth.swift @@ -0,0 +1,87 @@ +import LocalAuthentication + +/// Result of a biometric authentication attempt +enum BiometricAuthResult { + case success + case cancelled + case failed(message: String) +} + +/// Utility for biometric authentication (Face ID / Touch ID) +enum BiometricAuth { + /// The display name for the current biometry type + static var biometryTypeName: String { + switch Env.biometryType { + case .touchID: + return t("security__bio_touch_id") + case .faceID: + return t("security__bio_face_id") + default: + return t("security__bio_face_id") + } + } + + /// Whether biometric authentication is available on this device + static var isAvailable: Bool { + let context = LAContext() + var error: NSError? + return context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) + } + + /// Authenticate using biometrics (Face ID or Touch ID) + /// - Returns: Result indicating success, cancellation, or failure with error message + @MainActor + static func authenticate() async -> BiometricAuthResult { + await withCheckedContinuation { continuation in + let context = LAContext() + var error: NSError? + + guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else { + let message = errorMessage(for: error) + if let message { + continuation.resume(returning: .failed(message: message)) + } else { + continuation.resume(returning: .cancelled) + } + return + } + + let reason = t("security__bio_confirm", variables: ["biometricsName": biometryTypeName]) + + context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, authError in + DispatchQueue.main.async { + if success { + Logger.debug("Biometric authentication successful", context: "BiometricAuth") + continuation.resume(returning: .success) + } else if let authError { + let message = errorMessage(for: authError) + if let message { + continuation.resume(returning: .failed(message: message)) + } else { + continuation.resume(returning: .cancelled) + } + } else { + continuation.resume(returning: .cancelled) + } + } + } + } + } + + /// Convert LAError to user-facing error message + /// Returns nil for user-initiated cancellations (no error to show) + private static func errorMessage(for error: Error?) -> String? { + guard let error else { return nil } + + let nsError = error as NSError + + switch nsError.code { + case LAError.biometryNotAvailable.rawValue, LAError.biometryNotEnrolled.rawValue: + return t("security__bio_not_available") + case LAError.userCancel.rawValue, LAError.userFallback.rawValue: + return nil + default: + return t("security__bio_error_message", variables: ["type": biometryTypeName]) + } + } +} diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index a3b878af..a7a940f0 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -564,7 +564,6 @@ extension AppViewModel { SettingsViewModel.shared.updatePinEnabledState() MigrationsService.shared.isShowingMigrationLoading = false - self.toast(type: .success, title: "Migration Complete", description: "Your wallet has been successfully migrated") } } else if MigrationsService.shared.isRestoringFromRNRemoteBackup { Task { diff --git a/Bitkit/ViewModels/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift index 37fdf469..ceeaafec 100644 --- a/Bitkit/ViewModels/NavigationViewModel.swift +++ b/Bitkit/ViewModels/NavigationViewModel.swift @@ -83,6 +83,11 @@ enum Route: Hashable { case electrumSettings case rgsSettings case addressViewer + case sweep + case sweepConfirm + case sweepFeeRate + case sweepFeeCustom + case sweepSuccess(txid: String) // Support settings case reportIssue diff --git a/Bitkit/ViewModels/SheetViewModel.swift b/Bitkit/ViewModels/SheetViewModel.swift index 529b76b5..7e55520b 100644 --- a/Bitkit/ViewModels/SheetViewModel.swift +++ b/Bitkit/ViewModels/SheetViewModel.swift @@ -18,6 +18,7 @@ enum SheetID: String, CaseIterable { case scanner case security case send + case sweepPrompt case tagFilter case dateRangeSelector } @@ -322,4 +323,16 @@ class SheetViewModel: ObservableObject { } } } + + var sweepPromptSheetItem: SweepPromptSheetItem? { + get { + guard let config = activeSheetConfiguration, config.id == .sweepPrompt else { return nil } + return SweepPromptSheetItem() + } + set { + if newValue == nil { + activeSheetConfiguration = nil + } + } + } } diff --git a/Bitkit/ViewModels/SweepViewModel.swift b/Bitkit/ViewModels/SweepViewModel.swift new file mode 100644 index 00000000..6165a340 --- /dev/null +++ b/Bitkit/ViewModels/SweepViewModel.swift @@ -0,0 +1,323 @@ +import BitkitCore +import Foundation +import SwiftUI + +/// Manages sweep transaction state and operations +@MainActor +class SweepViewModel: ObservableObject { + // MARK: - Published State + + /// Current state of the sweep check + @Published var checkState: CheckState = .idle + + /// Sweepable balances from external wallets + @Published var sweepableBalances: SweepableBalances? + + /// Transaction preview after preparation + @Published var transactionPreview: SweepTransactionPreview? + + /// Selected fee rate in sats/vbyte + @Published var selectedFeeRate: UInt32? + + /// Available fee rates + @Published var feeRates: FeeRates? + + /// Selected transaction speed + @Published var selectedSpeed: TransactionSpeed = .normal + + /// Error message to display + @Published var errorMessage: String? + + /// Result after broadcast + @Published var sweepResult: SweepResult? + + /// Destination address for the sweep + @Published var destinationAddress: String? + + /// Whether a transaction is currently being prepared + @Published var isPreparingTransaction = false + + // MARK: - Types + + enum CheckState { + case idle + case checking + case found(balance: UInt64) + case noFunds + case error(String) + } + + enum SweepState { + case idle + case preparing + case ready + case broadcasting + case success(SweepResult) + case error(String) + + var isLoading: Bool { + switch self { + case .idle, .preparing: + return true + default: + return false + } + } + } + + @Published var sweepState: SweepState = .idle + + // MARK: - Private Properties + + private let walletIndex: Int + + // MARK: - Computed Properties + + var totalBalance: UInt64 { + sweepableBalances?.totalBalance ?? 0 + } + + var hasBalance: Bool { + totalBalance > 0 + } + + var estimatedFee: UInt64 { + transactionPreview?.estimatedFee ?? 0 + } + + var amountAfterFees: UInt64 { + transactionPreview?.amountAfterFees ?? 0 + } + + var utxosCount: UInt32 { + sweepableBalances?.totalUtxosCount ?? 0 + } + + // MARK: - Initialization + + init(walletIndex: Int = 0) { + self.walletIndex = walletIndex + } + + // MARK: - Public Methods + + /// Check for sweepable balances from external addresses + func checkBalance() async { + checkState = .checking + errorMessage = nil + + do { + let mnemonic = try getMnemonic() + let passphrase = try getPassphrase() + let electrumUrl = Self.getElectrumUrl() + let network = Env.bitkitCoreNetwork + + let balances = try await BitkitCore.checkSweepableBalances( + mnemonicPhrase: mnemonic, + network: network, + bip39Passphrase: passphrase, + electrumUrl: electrumUrl + ) + + sweepableBalances = balances + + if balances.totalBalance > 0 { + checkState = .found(balance: balances.totalBalance) + } else { + checkState = .noFunds + } + } catch { + Logger.error("Failed to check sweepable balance: \(error)", context: "SweepViewModel") + checkState = .error(error.localizedDescription) + errorMessage = error.localizedDescription + } + } + + /// Prepare the sweep transaction + func prepareSweep(destinationAddress: String) async { + self.destinationAddress = destinationAddress + sweepState = .preparing + isPreparingTransaction = true + errorMessage = nil + + guard let selectedFeeRate, selectedFeeRate > 0 else { + let error = t("sweep__error_fee_rate_not_set") + sweepState = .error(error) + errorMessage = error + isPreparingTransaction = false + return + } + + do { + let mnemonic = try getMnemonic() + let passphrase = try getPassphrase() + let electrumUrl = Self.getElectrumUrl() + let network = Env.bitkitCoreNetwork + + let preview = try await BitkitCore.prepareSweepTransaction( + mnemonicPhrase: mnemonic, + network: network, + bip39Passphrase: passphrase, + electrumUrl: electrumUrl, + destinationAddress: destinationAddress, + feeRateSatsPerVbyte: selectedFeeRate + ) + + transactionPreview = preview + sweepState = .ready + } catch { + Logger.error("Failed to prepare sweep: \(error)", context: "SweepViewModel") + sweepState = .error(error.localizedDescription) + errorMessage = error.localizedDescription + } + + isPreparingTransaction = false + } + + /// Broadcast the sweep transaction + func broadcastSweep() async { + guard let preview = transactionPreview else { + sweepState = .error("No transaction prepared") + return + } + + sweepState = .broadcasting + errorMessage = nil + + do { + let mnemonic = try getMnemonic() + let passphrase = try getPassphrase() + let electrumUrl = Self.getElectrumUrl() + let network = Env.bitkitCoreNetwork + + let result = try await BitkitCore.broadcastSweepTransaction( + psbt: preview.psbt, + mnemonicPhrase: mnemonic, + network: network, + bip39Passphrase: passphrase, + electrumUrl: electrumUrl + ) + + sweepResult = result + sweepState = .success(result) + } catch { + Logger.error("Failed to broadcast sweep: \(error)", context: "SweepViewModel") + sweepState = .error(error.localizedDescription) + errorMessage = error.localizedDescription + } + } + + /// Set fee rate based on selected speed + func setFeeRate(speed: TransactionSpeed) async { + selectedSpeed = speed + + switch speed { + case let .custom(rate): + selectedFeeRate = rate + default: + if let rates = feeRates { + selectedFeeRate = speed.getFeeRate(from: rates) + } + } + } + + /// Load current fee estimates + func loadFeeEstimates() async throws { + var rates = try? await CoreService.shared.blocktank.fees(refresh: true) + + if rates == nil { + Logger.warn("Failed to fetch fresh fee rate, using cached rate.", context: "SweepViewModel") + rates = try await CoreService.shared.blocktank.fees(refresh: false) + } + + guard let rates else { + throw AppError(message: "Fee rates unavailable", debugMessage: nil) + } + + feeRates = rates + selectedFeeRate = selectedSpeed.getFeeRate(from: rates) + } + + /// Reset the view model state + func reset() { + checkState = .idle + sweepState = .idle + isPreparingTransaction = false + sweepableBalances = nil + transactionPreview = nil + sweepResult = nil + errorMessage = nil + selectedFeeRate = nil + selectedSpeed = .normal + destinationAddress = nil + } + + // MARK: - Private Methods + + private func getMnemonic() throws -> String { + guard let mnemonic = try Keychain.loadString(key: .bip39Mnemonic(index: walletIndex)) else { + throw NSError( + domain: "SweepViewModel", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Mnemonic not found"] + ) + } + return mnemonic + } + + private func getPassphrase() throws -> String? { + try Keychain.loadString(key: .bip39Passphrase(index: walletIndex)) + } + + private static func getElectrumUrl() -> String { + let configService = ElectrumConfigService() + let server = configService.getCurrentServer() + return server.fullUrl.isEmpty ? Env.electrumServerUrl : server.fullUrl + } + + // MARK: - Static Methods + + /// Check for sweepable funds after migration/restore and show prompt sheet if funds found + static func checkAndPromptForSweepableFunds(sheets: SheetViewModel) { + Task { + let hasSweepableFunds = await checkForSweepableFundsAfterMigration() + if hasSweepableFunds { + await MainActor.run { + sheets.showSheet(.sweepPrompt) + } + } + } + } + + /// Check for sweepable funds after migration and return true if funds were found + static func checkForSweepableFundsAfterMigration(walletIndex: Int = 0) async -> Bool { + do { + guard let mnemonic = try Keychain.loadString(key: .bip39Mnemonic(index: walletIndex)) else { + Logger.debug("No mnemonic found for sweep check", context: "SweepViewModel") + return false + } + + let passphrase = try? Keychain.loadString(key: .bip39Passphrase(index: walletIndex)) + let electrumUrl = Self.getElectrumUrl() + let network = Env.bitkitCoreNetwork + + let balances = try await BitkitCore.checkSweepableBalances( + mnemonicPhrase: mnemonic, + network: network, + bip39Passphrase: passphrase, + electrumUrl: electrumUrl + ) + + if balances.totalBalance > 0 { + Logger.info("Found \(balances.totalBalance) sats to sweep after migration", context: "SweepViewModel") + return true + } + + Logger.debug("No sweepable funds found after migration", context: "SweepViewModel") + return false + } catch { + Logger.error("Failed to check sweepable funds after migration: \(error)", context: "SweepViewModel") + return false + } + } +} diff --git a/Bitkit/Views/Onboarding/InitializingWalletView.swift b/Bitkit/Views/Onboarding/InitializingWalletView.swift index b9c35564..894d90d4 100644 --- a/Bitkit/Views/Onboarding/InitializingWalletView.swift +++ b/Bitkit/Views/Onboarding/InitializingWalletView.swift @@ -166,6 +166,12 @@ struct InitializingWalletView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } } + .onAppear { + UIApplication.shared.isIdleTimerDisabled = true + } + .onDisappear { + UIApplication.shared.isIdleTimerDisabled = false + } } } diff --git a/Bitkit/Views/Onboarding/WalletRestoreSuccess.swift b/Bitkit/Views/Onboarding/WalletRestoreSuccess.swift index 1e722d04..3cce6c7e 100644 --- a/Bitkit/Views/Onboarding/WalletRestoreSuccess.swift +++ b/Bitkit/Views/Onboarding/WalletRestoreSuccess.swift @@ -2,6 +2,7 @@ import SwiftUI struct WalletRestoreSuccess: View { @EnvironmentObject var wallet: WalletViewModel + @EnvironmentObject var sheets: SheetViewModel @EnvironmentObject var suggestionsManager: SuggestionsManager @EnvironmentObject var tagManager: TagManager @@ -32,6 +33,7 @@ struct WalletRestoreSuccess: View { tagManager.reloadLastUsedTags() wallet.isRestoringWallet = false + SweepViewModel.checkAndPromptForSweepableFunds(sheets: sheets) } .accessibilityIdentifier("GetStartedButton") } diff --git a/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift b/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift index 2f707f90..dde38459 100644 --- a/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift +++ b/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift @@ -79,6 +79,11 @@ struct AdvancedSettingsView: View { } .accessibilityIdentifier("AddressViewer") + NavigationLink(value: Route.sweep) { + SettingsListLabel(title: t("settings__adv__sweep_funds")) + } + .accessibilityIdentifier("SweepFunds") + // SettingsListLabel(title: t("settings__adv__rescan"), rightIcon: nil) Button(action: { diff --git a/Bitkit/Views/Settings/Advanced/SweepConfirmView.swift b/Bitkit/Views/Settings/Advanced/SweepConfirmView.swift new file mode 100644 index 00000000..aa5fa378 --- /dev/null +++ b/Bitkit/Views/Settings/Advanced/SweepConfirmView.swift @@ -0,0 +1,245 @@ +import BitkitCore +import SwiftUI + +struct SweepConfirmView: View { + @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var settings: SettingsViewModel + @EnvironmentObject private var viewModel: SweepViewModel + + @State private var showPinCheck = false + @State private var pinCheckContinuation: CheckedContinuation? + @State private var showingBiometricError = false + @State private var biometricErrorMessage = "" + @State private var isLoadingAddress = true + + private var isLoading: Bool { + isLoadingAddress || viewModel.isPreparingTransaction || viewModel.sweepState.isLoading + } + + var body: some View { + ZStack { + VStack(alignment: .leading, spacing: 0) { + NavigationBar(title: t("sweep__confirm_title")) + .padding(.bottom, 16) + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 24) { + VStack(alignment: .leading, spacing: 8) { + if case .ready = viewModel.sweepState, !viewModel.isPreparingTransaction { + MoneyStack( + sats: Int(viewModel.amountAfterFees), + showSymbol: true, + testIdPrefix: "SweepAmount" + ) + } else { + MoneyStack( + sats: Int(viewModel.totalBalance), + showSymbol: true, + testIdPrefix: "SweepAmount" + ) + .opacity(0.5) + } + } + + Divider() + + // Destination section + VStack(alignment: .leading, spacing: 8) { + CaptionMText(t("sweep__destination")) + + if let address = viewModel.destinationAddress { + BodySSBText(address.ellipsis(maxLength: 20)) + .lineLimit(1) + .truncationMode(.middle) + } else { + BodySSBText("...") + .opacity(0.5) + } + } + + Divider() + + // Fee section + Button(action: { + navigation.navigate(.sweepFeeRate) + }) { + HStack { + VStack(alignment: .leading, spacing: 8) { + CaptionMText(t("wallet__send_fee_and_speed")) + HStack(spacing: 0) { + Image(viewModel.selectedSpeed.iconName) + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(viewModel.selectedSpeed.iconColor) + .frame(width: 16, height: 16) + .padding(.trailing, 4) + + if viewModel.estimatedFee > 0, !viewModel.isPreparingTransaction { + HStack(spacing: 0) { + BodySSBText("\(viewModel.selectedSpeed.displayTitle) (") + MoneyText(sats: Int(viewModel.estimatedFee), size: .bodySSB, symbol: true, symbolColor: .textPrimary) + BodySSBText(")") + } + + Image("pencil") + .foregroundColor(.textPrimary) + .frame(width: 12, height: 12) + .padding(.leading, 6) + } else { + BodySSBText(viewModel.selectedSpeed.displayTitle) + } + } + } + + 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(viewModel.selectedSpeed.displayDescription) + } + } + } + } + .buttonStyle(.plain) + .disabled(isLoading) + + Divider() + + // Error display + if let error = viewModel.errorMessage { + HStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.redAccent) + BodyMText(error) + .foregroundColor(.redAccent) + } + .padding() + .background(Color.redAccent.opacity(0.1)) + .cornerRadius(8) + } + } + } + + Spacer() + + // Bottom button area + if case .broadcasting = viewModel.sweepState { + VStack(spacing: 32) { + ActivityIndicator(size: 32) + CaptionMText(t("sweep__broadcasting")) + .foregroundColor(.textSecondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + } else if isLoading { + VStack(spacing: 32) { + ActivityIndicator(size: 32) + CaptionMText(t("sweep__preparing")) + .foregroundColor(.textSecondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + } else if case .ready = viewModel.sweepState, viewModel.destinationAddress != nil { + SwipeButton(title: t("sweep__swipe"), accentColor: .greenAccent) { + // Check if authentication is required + if settings.requirePinForPayments && settings.pinEnabled { + if settings.useBiometrics && BiometricAuth.isAvailable { + let result = await BiometricAuth.authenticate() + switch result { + case .success: + break + case .cancelled: + throw CancellationError() + case let .failed(message): + biometricErrorMessage = message + showingBiometricError = true + throw CancellationError() + } + } else { + showPinCheck = true + let shouldProceed = try await waitForPinCheck() + if !shouldProceed { + throw CancellationError() + } + } + } + + // Broadcast the sweep + await viewModel.broadcastSweep() + + if case let .success(result) = viewModel.sweepState { + navigation.navigate(.sweepSuccess(txid: result.txid)) + } + } + } + } + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + .task { + await loadDestinationAddress() + do { + try await viewModel.loadFeeEstimates() + } catch { + Logger.error("Failed to load fee estimates: \(error)", context: "SweepConfirmView") + viewModel.errorMessage = error.localizedDescription + } + if let address = viewModel.destinationAddress { + await viewModel.prepareSweep(destinationAddress: address) + } + } + .onChange(of: viewModel.selectedSpeed) { _ in + Task { + if let address = viewModel.destinationAddress { + await viewModel.prepareSweep(destinationAddress: address) + } + } + } + .alert( + t("security__bio_error_title"), + isPresented: $showingBiometricError + ) { + Button(t("common__ok")) {} + } message: { + Text(biometricErrorMessage) + } + .navigationDestination(isPresented: $showPinCheck) { + PinCheckView( + title: t("security__pin_send_title"), + explanation: t("security__pin_send"), + onCancel: { + pinCheckContinuation?.resume(returning: false) + pinCheckContinuation = nil + }, + onPinVerified: { _ in + pinCheckContinuation?.resume(returning: true) + pinCheckContinuation = nil + } + ) + } + } + + private func loadDestinationAddress() async { + isLoadingAddress = true + do { + viewModel.destinationAddress = try await LightningService.shared.newAddress() + } catch { + Logger.error("Failed to get destination address: \(error)", context: "SweepConfirmView") + viewModel.errorMessage = t("sweep__error_destination_address") + } + isLoadingAddress = false + } + + private func waitForPinCheck() async throws -> Bool { + try await withCheckedThrowingContinuation { continuation in + pinCheckContinuation = continuation + } + } +} diff --git a/Bitkit/Views/Settings/Advanced/SweepFeeCustomView.swift b/Bitkit/Views/Settings/Advanced/SweepFeeCustomView.swift new file mode 100644 index 00000000..28abdcca --- /dev/null +++ b/Bitkit/Views/Settings/Advanced/SweepFeeCustomView.swift @@ -0,0 +1,111 @@ +import SwiftUI + +struct SweepFeeCustomView: View { + @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject private var viewModel: SweepViewModel + + @State private var feeRate: UInt32 = 1 + @State private var transactionFee: UInt64 = 0 + + private let minFee: UInt32 = 1 + private let maxFee: UInt32 = 999 + + private var isValid: Bool { + feeRate >= minFee && feeRate <= maxFee + } + + private var estimatedTxVbytes: UInt64 { + viewModel.transactionPreview?.estimatedVsize ?? 0 + } + + private var totalFeeText: String { + let fee = UInt64(feeRate) * estimatedTxVbytes + return t("sweep__fee_total", variables: ["fee": String(fee)]) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar(title: t("wallet__send_fee_custom")) + .padding(.horizontal, 16) + + VStack(alignment: .leading, spacing: 0) { + CaptionMText(t("common__sat_vbyte")) + .padding(.bottom, 16) + .padding(.horizontal, 16) + + HStack { + MoneyText(sats: Int(feeRate), symbol: true, color: feeRate == 0 ? .textSecondary : .textPrimary) + } + .padding(.bottom, 16) + .padding(.horizontal, 16) + + if isValid { + BodyMText(totalFeeText) + .padding(.bottom, 32) + .padding(.horizontal, 16) + } + + Spacer() + + NumberPad { key in + handleNumberPadInput(key) + } + .padding(.horizontal, 16) + + CustomButton(title: t("common__continue")) { + onContinue() + } + .disabled(!isValid) + .padding(.horizontal, 16) + .padding(.top, 16) + } + } + .navigationBarHidden(true) + .bottomSafeAreaPadding() + .task { + initializeFromCurrentFee() + } + } + + private func initializeFromCurrentFee() { + if case let .custom(rate) = viewModel.selectedSpeed { + feeRate = rate + } else { + feeRate = viewModel.selectedFeeRate ?? 0 + } + } + + private func handleNumberPadInput(_ key: String) { + let current = String(feeRate) + + if key == "delete" { + if current.count > 1 { + let newString = String(current.dropLast()) + feeRate = UInt32(newString) ?? 0 + } else { + feeRate = 0 + } + } else { + let newString: String = if current == "0" { + key + } else { + current + key + } + + // Limit to 3 digits (max 999 sat/vB) + if newString.count <= 3, let newRate = UInt32(newString) { + feeRate = newRate + } + } + } + + private func onContinue() { + guard isValid else { return } + + Task { + await viewModel.setFeeRate(speed: .custom(satsPerVByte: feeRate)) + viewModel.selectedFeeRate = feeRate + navigation.navigateBack() + } + } +} diff --git a/Bitkit/Views/Settings/Advanced/SweepFeeRateView.swift b/Bitkit/Views/Settings/Advanced/SweepFeeRateView.swift new file mode 100644 index 00000000..9bcb2a7e --- /dev/null +++ b/Bitkit/Views/Settings/Advanced/SweepFeeRateView.swift @@ -0,0 +1,148 @@ +import BitkitCore +import SwiftUI + +struct SweepFeeRateView: View { + @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var wallet: WalletViewModel + @EnvironmentObject private var viewModel: SweepViewModel + + @State private var isLoading = true + + private var estimatedTxVbytes: UInt64 { + viewModel.transactionPreview?.estimatedVsize ?? 0 + } + + private func getFee(for speed: TransactionSpeed) -> UInt64 { + let feeRate: UInt32 + switch speed { + case let .custom(rate): + feeRate = rate + default: + guard let rates = viewModel.feeRates else { return 0 } + feeRate = speed.getFeeRate(from: rates) + } + return UInt64(feeRate) * estimatedTxVbytes + } + + private func getAmountAfterFee(for speed: TransactionSpeed) -> UInt64 { + let fee = getFee(for: speed) + let total = viewModel.totalBalance + return total > fee ? total - fee : 0 + } + + private func isDisabled(for speed: TransactionSpeed) -> Bool { + let fee = getFee(for: speed) + let totalBalance = viewModel.totalBalance + // Disable if fee would leave less than dust limit + return fee + UInt64(Env.dustLimit) > totalBalance + } + + private func selectFee(_ speed: TransactionSpeed) { + Task { + await viewModel.setFeeRate(speed: speed) + navigation.navigateBack() + } + } + + private var currentCustomFeeRate: UInt32 { + if case let .custom(rate) = viewModel.selectedSpeed { + return rate + } else { + return viewModel.selectedFeeRate ?? 0 + } + } + + private var isCustomSelected: Bool { + if case .custom = viewModel.selectedSpeed { + return true + } + return false + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar(title: t("wallet__send_fee_speed")) + .padding(.horizontal, 16) + + VStack(alignment: .leading, spacing: 0) { + CaptionMText(t("wallet__send_fee_and_speed")) + .padding(.bottom, 16) + .padding(.horizontal, 16) + + if isLoading { + HStack { + Spacer() + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .brandAccent)) + Spacer() + } + .padding(.top, 32) + } else { + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + FeeItem( + speed: .fast, + amount: getFee(for: .fast), + isSelected: viewModel.selectedSpeed == .fast, + isDisabled: isDisabled(for: .fast) + ) { + selectFee(.fast) + } + + FeeItem( + speed: .normal, + amount: getFee(for: .normal), + isSelected: viewModel.selectedSpeed == .normal, + isDisabled: isDisabled(for: .normal) + ) { + selectFee(.normal) + } + + FeeItem( + speed: .slow, + amount: getFee(for: .slow), + isSelected: viewModel.selectedSpeed == .slow, + isDisabled: isDisabled(for: .slow) + ) { + selectFee(.slow) + } + + // Custom fee option + FeeItem( + speed: .custom(satsPerVByte: currentCustomFeeRate), + amount: getFee(for: .custom(satsPerVByte: currentCustomFeeRate)), + isSelected: isCustomSelected, + isDisabled: false + ) { + navigation.navigate(.sweepFeeCustom) + } + } + } + } + + Spacer() + + CustomButton(title: t("common__continue")) { + navigation.navigateBack() + } + .padding(.horizontal, 16) + } + } + .navigationBarHidden(true) + .bottomSafeAreaPadding() + .task { + await loadFeeEstimates() + } + } + + private func loadFeeEstimates() async { + isLoading = true + do { + try await viewModel.loadFeeEstimates() + } catch { + Logger.error("Failed to load fee estimates: \(error)", context: "SweepFeeRateView") + viewModel.errorMessage = error.localizedDescription + } + isLoading = false + } +} diff --git a/Bitkit/Views/Settings/Advanced/SweepPromptSheet.swift b/Bitkit/Views/Settings/Advanced/SweepPromptSheet.swift new file mode 100644 index 00000000..e11817ac --- /dev/null +++ b/Bitkit/Views/Settings/Advanced/SweepPromptSheet.swift @@ -0,0 +1,39 @@ +import SwiftUI + +struct SweepPromptSheetItem: SheetItem { + let id: SheetID = .sweepPrompt + let size: SheetSize = .large +} + +struct SweepPromptSheet: View { + @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var sheets: SheetViewModel + let config: SweepPromptSheetItem + + var body: some View { + Sheet(id: .sweepPrompt, data: config) { + SheetIntro( + navTitle: t("sweep__prompt_title"), + title: t("sweep__prompt_headline"), + description: t("sweep__prompt_description"), + image: "coin-stack", + continueText: t("sweep__prompt_sweep"), + cancelText: t("common__cancel"), + testID: "SweepPromptSheet", + onCancel: { + sheets.hideSheet() + }, + onContinue: { + sheets.hideSheet() + navigation.navigate(.sweep) + } + ) + } + } +} + +#Preview { + SweepPromptSheet(config: SweepPromptSheetItem()) + .environmentObject(NavigationViewModel()) + .environmentObject(SheetViewModel()) +} diff --git a/Bitkit/Views/Settings/Advanced/SweepSettingsView.swift b/Bitkit/Views/Settings/Advanced/SweepSettingsView.swift new file mode 100644 index 00000000..3c6e32ce --- /dev/null +++ b/Bitkit/Views/Settings/Advanced/SweepSettingsView.swift @@ -0,0 +1,197 @@ +import BitkitCore +import Lottie +import SwiftUI + +struct SweepSettingsView: View { + @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject private var viewModel: SweepViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar(title: navigationTitle) + .padding(.bottom, 30) + + switch viewModel.checkState { + case .idle, .checking: + loadingView + case .found: + foundFundsView + case .noFunds: + noFundsView + case let .error(message): + errorView(message: message) + } + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + .background(Color.customBlack) + .task { + viewModel.reset() + await viewModel.checkBalance() + } + } + + private var navigationTitle: String { + switch viewModel.checkState { + case .found: + return t("sweep__found_title") + case .noFunds: + return t("sweep__no_funds_title") + default: + return t("sweep__title") + } + } + + // MARK: - Loading View + + @ViewBuilder + private var loadingView: some View { + VStack(alignment: .leading, spacing: 0) { + BodyMText(t("sweep__loading_description")) + .foregroundColor(.textSecondary) + + Spacer() + + // Magnifying glass image + Image("magnifying-glass-illustration") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 311, height: 311) + .frame(maxWidth: .infinity, alignment: .center) + + Spacer() + + // Loading indicator + VStack(spacing: 32) { + ActivityIndicator(size: 32) + + CaptionMText(t("sweep__looking_for_funds")) + .foregroundColor(.textSecondary) + } + .frame(maxWidth: .infinity, alignment: .center) + } + } + + // MARK: - Found Funds View + + @ViewBuilder + private var foundFundsView: some View { + VStack(alignment: .leading, spacing: 0) { + BodyMText(t("sweep__found_description")) + .foregroundColor(.textSecondary) + .padding(.bottom, 24) + + CaptionMText(t("sweep__funds_found")) + .foregroundColor(.textSecondary) + .padding(.bottom, 16) + + if let balances = viewModel.sweepableBalances { + VStack(alignment: .leading, spacing: 0) { + if balances.legacyBalance > 0 { + fundRow( + title: "Legacy (P2PKH)", + utxoCount: balances.legacyUtxosCount, + balance: balances.legacyBalance + ) + } + if balances.p2shBalance > 0 { + fundRow( + title: "SegWit (P2SH)", + utxoCount: balances.p2shUtxosCount, + balance: balances.p2shBalance + ) + } + if balances.taprootBalance > 0 { + fundRow( + title: "Taproot (P2TR)", + utxoCount: balances.taprootUtxosCount, + balance: balances.taprootBalance + ) + } + + // Total row + HStack { + TitleText(t("common__total")) + Spacer() + MoneyText(sats: Int(balances.totalBalance), size: .title, symbol: true, symbolColor: .textPrimary) + } + .padding(.top, 16) + } + } + + Spacer() + + CustomButton(title: t("sweep__sweep_to_wallet")) { + navigation.navigate(.sweepConfirm) + } + } + } + + @ViewBuilder + private func fundRow(title: String, utxoCount: UInt32, balance: UInt64) -> some View { + VStack(spacing: 0) { + HStack { + Text("\(title), \(utxoCount) UTXO\(utxoCount == 1 ? "" : "s")") + .font(Fonts.semiBold(size: 13)) + .foregroundColor(.textPrimary) + Spacer() + MoneyText(sats: Int(balance), size: .captionB, symbol: true, symbolColor: .textPrimary) + } + .padding(.vertical, 16) + + Divider() + .background(Color.gray5) + } + } + + // MARK: - No Funds View + + @ViewBuilder + private var noFundsView: some View { + VStack(alignment: .leading, spacing: 0) { + BodyMText(t("sweep__no_funds_description")) + .foregroundColor(.textSecondary) + + Spacer() + + Image("check") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 311, height: 311) + .frame(maxWidth: .infinity, alignment: .center) + + Spacer() + + CustomButton(title: t("common__ok")) { + navigation.navigateBack() + } + } + } + + // MARK: - Error View + + @ViewBuilder + private func errorView(message: String) -> some View { + VStack(spacing: 24) { + Spacer() + + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 64)) + .foregroundColor(.redAccent) + + VStack(spacing: 8) { + BodyMSBText(t("sweep__error_title")) + BodyMText(message) + .foregroundColor(.textSecondary) + .multilineTextAlignment(.center) + } + + Spacer() + + CustomButton(title: t("common__retry")) { + Task { await viewModel.checkBalance() } + } + } + } +} diff --git a/Bitkit/Views/Settings/Advanced/SweepSuccessView.swift b/Bitkit/Views/Settings/Advanced/SweepSuccessView.swift new file mode 100644 index 00000000..3145ea36 --- /dev/null +++ b/Bitkit/Views/Settings/Advanced/SweepSuccessView.swift @@ -0,0 +1,64 @@ +import BitkitCore +import Lottie +import SwiftUI + +struct SweepSuccessView: View { + @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var viewModel: SweepViewModel + + let txid: String + + private var confettiAnimation: LottieAnimation? { + guard let filepathURL = Bundle.main.url(forResource: "confetti-orange", withExtension: "json") else { + return nil + } + return LottieAnimation.filepath(filepathURL.path) + } + + private var amountSwept: UInt64 { + viewModel.sweepResult?.amountSwept ?? 0 + } + + var body: some View { + ZStack { + if let animation = confettiAnimation { + LottieView(animation: animation) + .playing(loopMode: .loop) + .scaleEffect(1.9) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + VStack(alignment: .leading, spacing: 0) { + NavigationBar(title: t("sweep__complete_title")) + .padding(.bottom, 16) + + BodyMText(t("sweep__complete_description")) + .foregroundColor(.textSecondary) + .padding(.bottom, 24) + + VStack(alignment: .leading, spacing: 16) { + MoneyText(sats: Int(amountSwept), unitType: .secondary, size: .caption, color: .textSecondary) + MoneyText(sats: Int(amountSwept), size: .display, symbol: true, symbolColor: .textSecondary) + } + + Spacer() + + Image("check") + .resizable() + .scaledToFit() + .frame(width: 256, height: 256) + .frame(maxWidth: .infinity) + + Spacer() + + CustomButton(title: t("sweep__wallet_overview")) { + navigation.reset() + } + } + .padding(.horizontal, 16) + } + .navigationBarHidden(true) + .bottomSafeAreaPadding() + .background(Color.customBlack) + } +} diff --git a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift index cd39e4d1..83cc9d2d 100644 --- a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift +++ b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift @@ -1,5 +1,4 @@ import BitkitCore -import LocalAuthentication import SwiftUI struct LnurlPayConfirm: View { @@ -18,23 +17,6 @@ struct LnurlPayConfirm: View { @State private var comment = "" @FocusState private var isCommentFocused: Bool - private var biometryTypeName: String { - switch Env.biometryType { - case .touchID: - return t("security__bio_touch_id") - case .faceID: - return t("security__bio_face_id") - default: - return t("security__bio_face_id") // Default to Face ID - } - } - - private var isBiometricAvailable: Bool { - let context = LAContext() - var error: NSError? - return context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) - } - var uri: String { app.lnurlPayData!.uri } @@ -141,27 +123,27 @@ struct LnurlPayConfirm: View { // Check if authentication is required for payments if settings.requirePinForPayments && settings.pinEnabled { - // Use biometrics if available and enabled, otherwise use PIN - if settings.useBiometrics && isBiometricAvailable { - let shouldProceed = try await requestBiometricAuthentication() - if !shouldProceed { - // User cancelled biometric authentication, throw error to reset SwipeButton + if settings.useBiometrics && BiometricAuth.isAvailable { + let result = await BiometricAuth.authenticate() + switch result { + case .success: + break + case .cancelled: + throw CancellationError() + case let .failed(message): + biometricErrorMessage = message + showingBiometricError = true throw CancellationError() } - // Biometric authentication successful, continue with payment } else { - // Fall back to PIN showPinCheck = true let shouldProceed = try await waitForPinCheck() if !shouldProceed { - // User cancelled PIN entry, throw error to reset SwipeButton throw CancellationError() } - // PIN verified, continue with payment } } - // Proceed with payment try await performPayment() } } @@ -218,66 +200,6 @@ struct LnurlPayConfirm: View { } } - private func requestBiometricAuthentication() async throws -> Bool { - return try await withCheckedThrowingContinuation { continuation in - let context = LAContext() - var error: NSError? - - // Check if biometric authentication is available - guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else { - handleBiometricError(error) - continuation.resume(returning: false) - return - } - - // Request biometric authentication - let reason = t( - "security__bio_confirm", - variables: ["biometricsName": biometryTypeName] - ) - - context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, authenticationError in - DispatchQueue.main.async { - if success { - Logger.debug("Biometric authentication successful for payment", context: "SendConfirmationView") - continuation.resume(returning: true) - } else { - if let error = authenticationError { - handleBiometricError(error) - } - continuation.resume(returning: false) - } - } - } - } - } - - private func handleBiometricError(_ error: Error?) { - guard let error else { return } - - let nsError = error as NSError - - switch nsError.code { - case LAError.biometryNotAvailable.rawValue: - biometricErrorMessage = t("security__bio_not_available") - showingBiometricError = true - case LAError.biometryNotEnrolled.rawValue: - biometricErrorMessage = t("security__bio_not_available") - showingBiometricError = true - case LAError.userCancel.rawValue, LAError.userFallback.rawValue: - // User cancelled - don't show error, just keep current state - return - default: - biometricErrorMessage = t( - "security__bio_error_message", - variables: ["type": biometryTypeName] - ) - showingBiometricError = true - } - - Logger.error("Biometric authentication error: \(error)", context: "SendConfirmationView") - } - private func performPayment() async throws { guard let lnurlPayData = app.lnurlPayData else { throw NSError(domain: "LNURL", code: -1, userInfo: [NSLocalizedDescriptionKey: "Missing LNURL pay data"]) diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 10fcabe0..59176bf1 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -1,5 +1,4 @@ import BitkitCore -import LocalAuthentication import SwiftUI struct SendConfirmationView: View { @@ -56,23 +55,6 @@ struct SendConfirmationView: View { @State private var pendingWarnings: [WarningType] = [] @State private var warningContinuation: CheckedContinuation? - private var biometryTypeName: String { - switch Env.biometryType { - case .touchID: - return t("security__bio_touch_id") - case .faceID: - return t("security__bio_face_id") - default: - return t("security__bio_face_id") // Default to Face ID - } - } - - private var isBiometricAvailable: Bool { - let context = LAContext() - var error: NSError? - return context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) - } - private var canEditAmount: Bool { guard app.selectedWalletToPayFrom == .lightning else { return true @@ -137,28 +119,27 @@ struct SendConfirmationView: View { // Check if authentication is required for payments if settings.requirePinForPayments && settings.pinEnabled { - // Use biometrics if available and enabled, otherwise use PIN - if settings.useBiometrics && isBiometricAvailable { - let shouldProceed = try await requestBiometricAuthentication() - if !shouldProceed { - // User cancelled biometric authentication, throw error to reset SwipeButton - + if settings.useBiometrics && BiometricAuth.isAvailable { + let result = await BiometricAuth.authenticate() + switch result { + case .success: + break + case .cancelled: + throw CancellationError() + case let .failed(message): + biometricErrorMessage = message + showingBiometricError = true throw CancellationError() } - // Biometric authentication successful, continue with payment } else { - // Fall back to PIN showPinCheck = true let shouldProceed = try await waitForPinCheck() if !shouldProceed { - // User cancelled PIN entry, throw error to reset SwipeButton throw CancellationError() } - // PIN verified, continue with payment } } - // Proceed with payment try await performPayment() } } @@ -226,66 +207,6 @@ struct SendConfirmationView: View { } } - private func requestBiometricAuthentication() async throws -> Bool { - return try await withCheckedThrowingContinuation { continuation in - let context = LAContext() - var error: NSError? - - // Check if biometric authentication is available - guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else { - handleBiometricError(error) - continuation.resume(returning: false) - return - } - - // Request biometric authentication - let reason = t( - "security__bio_confirm", - variables: ["biometricsName": biometryTypeName] - ) - - context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, authenticationError in - DispatchQueue.main.async { - if success { - Logger.debug("Biometric authentication successful for payment", context: "SendConfirmationView") - continuation.resume(returning: true) - } else { - if let error = authenticationError { - handleBiometricError(error) - } - continuation.resume(returning: false) - } - } - } - } - } - - private func handleBiometricError(_ error: Error?) { - guard let error else { return } - - let nsError = error as NSError - - switch nsError.code { - case LAError.biometryNotAvailable.rawValue: - biometricErrorMessage = t("security__bio_not_available") - showingBiometricError = true - case LAError.biometryNotEnrolled.rawValue: - biometricErrorMessage = t("security__bio_not_available") - showingBiometricError = true - case LAError.userCancel.rawValue, LAError.userFallback.rawValue: - // User cancelled - don't show error, just keep current state - return - default: - biometricErrorMessage = t( - "security__bio_error_message", - variables: ["type": biometryTypeName] - ) - showingBiometricError = true - } - - Logger.error("Biometric authentication error: \(error)", context: "SendConfirmationView") - } - private func performPayment() async throws { var createdMetadataPaymentId: String? = nil