Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

60 changes: 41 additions & 19 deletions Bitkit/AppScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ struct AppScene: View {
if UserDefaults.standard.bool(forKey: "pinOnLaunch") && settings.pinEnabled {
isPinVerified = false
}
SweepViewModel.checkAndPromptForSweepableFunds(sheets: sheets)
}
}
.environmentObject(app)
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 15 additions & 0 deletions Bitkit/MainNavView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
33 changes: 33 additions & 0 deletions Bitkit/Resources/Localization/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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\n<accent>BITKIT FUNDS</accent>";
"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\n<accent>WALLET</accent>";
"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.";
Expand Down
87 changes: 87 additions & 0 deletions Bitkit/Utilities/BiometricAuth.swift
Original file line number Diff line number Diff line change
@@ -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])
}
}
}
1 change: 0 additions & 1 deletion Bitkit/ViewModels/AppViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions Bitkit/ViewModels/NavigationViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions Bitkit/ViewModels/SheetViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ enum SheetID: String, CaseIterable {
case scanner
case security
case send
case sweepPrompt
case tagFilter
case dateRangeSelector
}
Expand Down Expand Up @@ -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
}
}
}
}
Loading
Loading