From 5a2da923e7f35bddcde99a0d649692b912d74dd2 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 29 Jun 2026 10:57:37 -0300 Subject: [PATCH 1/7] feat: implement hardware wallet setting screen --- Bitkit/MainNavView.swift | 1 + .../Localization/en.lproj/Localizable.strings | 3 + Bitkit/ViewModels/NavigationViewModel.swift | 1 + .../HardwareWalletsSettingsScreen.swift | 193 ++++++++++++++++++ .../Views/Settings/GeneralSettingsView.swift | 12 ++ changelog.d/next/hw-wallet-settings.added.md | 1 + 6 files changed, 211 insertions(+) create mode 100644 Bitkit/Views/Settings/General/HardwareWalletsSettingsScreen.swift create mode 100644 changelog.d/next/hw-wallet-settings.added.md diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 62d5c6ad4..55a331a34 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -540,6 +540,7 @@ struct MainNavView: View { case .notificationsIntro: NotificationsIntro() case .paymentPreference: if isPaykitUIActive { PaymentPreferenceView() } else { paykitDisabledRedirectView } + case .hardwareWalletsSettings: HardwareWalletsSettingsScreen() // Security settings case .changePin: ChangePinScreen() diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index b56a60446..48e19536d 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -686,6 +686,9 @@ "settings__general__language_other" = "Interface language"; "settings__general__section_interface" = "Interface"; "settings__general__section_payments" = "Payments"; +"settings__hardware_wallets__nav_title" = "Hardware Wallets"; +"settings__hardware_wallets__empty_text" = "Pair a hardware device to watch its balance and activity in Bitkit."; +"settings__hardware_wallets__add_button" = "Add Hardware Wallet"; "settings__widgets__nav_title" = "Widgets"; "settings__widgets__section_display" = "Display"; "settings__widgets__section_reset" = "Reset To Defaults"; diff --git a/Bitkit/ViewModels/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift index 69503430f..9c5d300c7 100644 --- a/Bitkit/ViewModels/NavigationViewModel.swift +++ b/Bitkit/ViewModels/NavigationViewModel.swift @@ -76,6 +76,7 @@ enum Route: Hashable { case notifications case notificationsIntro case paymentPreference + case hardwareWalletsSettings // Security case dataBackups diff --git a/Bitkit/Views/Settings/General/HardwareWalletsSettingsScreen.swift b/Bitkit/Views/Settings/General/HardwareWalletsSettingsScreen.swift new file mode 100644 index 000000000..8f3e5a4f1 --- /dev/null +++ b/Bitkit/Views/Settings/General/HardwareWalletsSettingsScreen.swift @@ -0,0 +1,193 @@ +import SwiftUI + +/// Manages all paired hardware wallets, reachable from Settings ▸ General ▸ Payments. Lists each +/// paired device with a connection badge, name, balance and a per-row delete; an empty state when +/// none are paired; and an "Add Hardware Wallet" button that opens the existing intro flow. Tapping +/// a row opens the device's `HardwareWalletScreen`. Ports bitkit-android's `HardwareWalletsSettingsScreen`. +struct HardwareWalletsSettingsScreen: View { + @Environment(HwWalletManager.self) private var hwWalletManager + @Environment(TrezorManager.self) private var trezorManager + @EnvironmentObject private var navigation: NavigationViewModel + @EnvironmentObject private var sheets: SheetViewModel + + @State private var pendingRemoval: HwWallet? + + private var wallets: [HwWallet] { + hwWalletManager.wallets + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar(title: t("settings__hardware_wallets__nav_title")) + .padding(.horizontal, 16) + + ZStack(alignment: .bottom) { + HwDeviceIllustrations() + .frame(maxWidth: .infinity) + .frame(maxHeight: .infinity, alignment: .bottom) + .padding(.bottom, ScreenLayout.bottomPaddingWithSafeArea) + .allowsHitTesting(false) + + VStack(spacing: 0) { + if wallets.isEmpty { + emptyState + .frame(maxHeight: .infinity, alignment: .center) + } else { + deviceList + } + + CustomButton( + title: t("settings__hardware_wallets__add_button"), + variant: .secondary, + shouldExpand: true + ) { + sheets.showSheet(.hardwareIntro) + } + .accessibilityIdentifier("AddHardwareWallet") + .padding(.bottom, 16) + } + .padding(.horizontal, 16) + } + } + .navigationBarHidden(true) + .accessibilityIdentifier("HardwareWalletsScreen") + .alert( + t("hardware__remove_dialog_title", variables: ["name": pendingRemoval?.name ?? ""]), + isPresented: Binding(get: { pendingRemoval != nil }, set: { if !$0 { pendingRemoval = nil } }) + ) { + Button(t("common__remove"), role: .destructive) { + guard let wallet = pendingRemoval else { return } + Task { await remove(wallet) } + } + Button(t("common__dialog_cancel"), role: .cancel) {} + } message: { + Text(t("hardware__remove_dialog_text")) + } + } + + private var emptyState: some View { + VStack(alignment: .leading, spacing: 8) { + DisplayText(t("settings__hardware_wallets__nav_title")) + BodyMText(t("settings__hardware_wallets__empty_text"), textColor: .white80) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var deviceList: some View { + ScrollView(showsIndicators: false) { + LazyVStack(spacing: 0) { + ForEach(wallets) { wallet in + HwWalletRow( + wallet: wallet, + onTap: { navigation.navigate(.hardwareWallet(deviceId: wallet.id)) }, + onRemove: { pendingRemoval = wallet } + ) + CustomDivider() + } + } + } + .frame(maxHeight: .infinity) + } + + /// Stop watching and forget every entry for the device (it may be paired over multiple + /// transports). Mirrors `HardwareWalletScreen.removeWallet()`: `removeDevice` stops the watchers + /// and deletes the persisted activities, then `forgetDevice` clears credentials and drops the + /// known-device entry, which re-pushes the snapshot and removes it from the list. + private func remove(_ wallet: HwWallet) async { + pendingRemoval = nil + hwWalletManager.removeDevice(id: wallet.id) + for id in wallet.deviceIds { + await trezorManager.forgetDevice(id: id) + } + } +} + +/// A paired hardware wallet row: connection badge, name, balance and a trailing delete. The badge, +/// name and balance navigate to the device's detail screen; the trash button removes it. +private struct HwWalletRow: View { + let wallet: HwWallet + let onTap: () -> Void + let onRemove: () -> Void + + var body: some View { + HStack(spacing: 0) { + Button(action: onTap) { + HStack(spacing: 12) { + HwConnectionBadge(isConnected: wallet.isConnected) + + BodyMText(wallet.name) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + + MoneyText( + sats: Int(clamping: wallet.balanceSats), + size: .bodySSB, + symbol: true, + color: .white64, + symbolColor: .white64 + ) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + Button(action: onRemove) { + Image("trash") + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + .foregroundColor(.white64) + .padding(.leading, 12) + .padding(.vertical, 8) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier("HardwareWalletRowDelete_\(wallet.id)") + } + .frame(height: 52) + .accessibilityIdentifier("HardwareWalletRow_\(wallet.id)") + } +} + +/// Circular connection badge tinting the shared Bluetooth glyph green when connected, gray otherwise. +private struct HwConnectionBadge: View { + let isConnected: Bool + + var body: some View { + ZStack { + Circle() + .fill(isConnected ? Color.green16 : Color.white16) + .frame(width: 32, height: 32) + + HwWalletConnectionIcon(isConnected: isConnected) + .frame(width: 16, height: 16) + } + } +} + +#Preview("With devices") { + NavigationStack { + HardwareWalletsSettingsScreen() + .environment(HwWalletManager()) + .environment(TrezorManager()) + .environmentObject(SheetViewModel()) + .environmentObject(NavigationViewModel()) + .environmentObject(CurrencyViewModel()) + .environmentObject(SettingsViewModel.shared) + } + .preferredColorScheme(.dark) +} + +#Preview("Empty") { + NavigationStack { + HardwareWalletsSettingsScreen() + .environment(HwWalletManager()) + .environment(TrezorManager()) + .environmentObject(SheetViewModel()) + .environmentObject(NavigationViewModel()) + .environmentObject(CurrencyViewModel()) + .environmentObject(SettingsViewModel.shared) + } + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Settings/GeneralSettingsView.swift b/Bitkit/Views/Settings/GeneralSettingsView.swift index ac80c34fd..1ac89ef14 100644 --- a/Bitkit/Views/Settings/GeneralSettingsView.swift +++ b/Bitkit/Views/Settings/GeneralSettingsView.swift @@ -5,6 +5,8 @@ struct GeneralSettingsView: View { @AppStorage(PublicPaykitService.lightningPaymentOptionEnabledKey) private var lightningPaymentOptionEnabled = true @AppStorage(PublicPaykitService.onchainPaymentOptionEnabledKey) private var onchainPaymentOptionEnabled = true + @Environment(HwWalletManager.self) private var hwWalletManager + @EnvironmentObject var app: AppViewModel @EnvironmentObject var currency: CurrencyViewModel @EnvironmentObject var pubkyProfile: PubkyProfileManager @@ -110,6 +112,15 @@ struct GeneralSettingsView: View { ) } .accessibilityIdentifier("NotificationsSettings") + + NavigationLink(value: Route.hardwareWalletsSettings) { + SettingsRow( + title: t("settings__hardware_wallets__nav_title"), + iconName: "device-mobile-speaker", + rightText: String(hwWalletManager.wallets.count) + ) + } + .accessibilityIdentifier("HardwareWalletsSettings") } .padding(.top, 16) .padding(.horizontal, 16) @@ -138,6 +149,7 @@ struct GeneralSettingsView: View { .environmentObject(CurrencyViewModel()) .environmentObject(PubkyProfileManager()) .environmentObject(AppViewModel()) + .environment(HwWalletManager()) } .preferredColorScheme(.dark) } diff --git a/changelog.d/next/hw-wallet-settings.added.md b/changelog.d/next/hw-wallet-settings.added.md new file mode 100644 index 000000000..9b9390f9b --- /dev/null +++ b/changelog.d/next/hw-wallet-settings.added.md @@ -0,0 +1 @@ +Added a Hardware Wallets settings screen, reachable from Settings ▸ General ▸ Payments, to view and remove paired devices. From 748234473d0c14d471e589ad298cd752b8fc26be Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 29 Jun 2026 11:28:00 -0300 Subject: [PATCH 2/7] fix: ilustration alignemnt --- .../Localization/en.lproj/Localizable.strings | 1 - .../HardwareWalletsSettingsScreen.swift | 81 ++++++++++--------- 2 files changed, 43 insertions(+), 39 deletions(-) diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 48e19536d..b6b04a723 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -687,7 +687,6 @@ "settings__general__section_interface" = "Interface"; "settings__general__section_payments" = "Payments"; "settings__hardware_wallets__nav_title" = "Hardware Wallets"; -"settings__hardware_wallets__empty_text" = "Pair a hardware device to watch its balance and activity in Bitkit."; "settings__hardware_wallets__add_button" = "Add Hardware Wallet"; "settings__widgets__nav_title" = "Widgets"; "settings__widgets__section_display" = "Display"; diff --git a/Bitkit/Views/Settings/General/HardwareWalletsSettingsScreen.swift b/Bitkit/Views/Settings/General/HardwareWalletsSettingsScreen.swift index 8f3e5a4f1..6fb3073e3 100644 --- a/Bitkit/Views/Settings/General/HardwareWalletsSettingsScreen.swift +++ b/Bitkit/Views/Settings/General/HardwareWalletsSettingsScreen.swift @@ -1,9 +1,9 @@ import SwiftUI /// Manages all paired hardware wallets, reachable from Settings ▸ General ▸ Payments. Lists each -/// paired device with a connection badge, name, balance and a per-row delete; an empty state when -/// none are paired; and an "Add Hardware Wallet" button that opens the existing intro flow. Tapping -/// a row opens the device's `HardwareWalletScreen`. Ports bitkit-android's `HardwareWalletsSettingsScreen`. +/// paired device with a connection badge, name, balance and a per-row delete; an intro-style empty +/// state when none are paired; and an "Add Hardware Wallet" button that opens the existing intro +/// flow. Tapping a row opens the device's `HardwareWalletScreen`. struct HardwareWalletsSettingsScreen: View { @Environment(HwWalletManager.self) private var hwWalletManager @Environment(TrezorManager.self) private var trezorManager @@ -17,37 +17,25 @@ struct HardwareWalletsSettingsScreen: View { } var body: some View { - VStack(alignment: .leading, spacing: 0) { + VStack(spacing: 0) { NavigationBar(title: t("settings__hardware_wallets__nav_title")) .padding(.horizontal, 16) - ZStack(alignment: .bottom) { - HwDeviceIllustrations() - .frame(maxWidth: .infinity) - .frame(maxHeight: .infinity, alignment: .bottom) - .padding(.bottom, ScreenLayout.bottomPaddingWithSafeArea) - .allowsHitTesting(false) - - VStack(spacing: 0) { - if wallets.isEmpty { - emptyState - .frame(maxHeight: .infinity, alignment: .center) - } else { - deviceList - } - - CustomButton( - title: t("settings__hardware_wallets__add_button"), - variant: .secondary, - shouldExpand: true - ) { - sheets.showSheet(.hardwareIntro) - } - .accessibilityIdentifier("AddHardwareWallet") - .padding(.bottom, 16) - } - .padding(.horizontal, 16) + if wallets.isEmpty { + emptyState + } else { + deviceList + } + + CustomButton( + title: t("settings__hardware_wallets__add_button"), + shouldExpand: true + ) { + sheets.showSheet(.hardwareIntro) } + .accessibilityIdentifier("AddHardwareWallet") + .padding(.horizontal, 16) + .padding(.bottom, 16) } .navigationBarHidden(true) .accessibilityIdentifier("HardwareWalletsScreen") @@ -65,12 +53,21 @@ struct HardwareWalletsSettingsScreen: View { } } + /// Mirrors the hardware intro sheet: staggered device hero filling the top, then an accent title + /// and copy above the Add button. private var emptyState: some View { - VStack(alignment: .leading, spacing: 8) { - DisplayText(t("settings__hardware_wallets__nav_title")) - BodyMText(t("settings__hardware_wallets__empty_text"), textColor: .white80) + VStack(spacing: 0) { + HwDeviceIllustrations() + .frame(maxWidth: .infinity, maxHeight: .infinity) + + VStack(alignment: .leading, spacing: 8) { + DisplayText(t("hardware__intro_header"), accentColor: .blueAccent) + BodyMText(t("hardware__intro_text")) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.bottom, 32) } - .frame(maxWidth: .infinity, alignment: .leading) } private var deviceList: some View { @@ -85,8 +82,16 @@ struct HardwareWalletsSettingsScreen: View { CustomDivider() } } + .padding(.horizontal, 16) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(alignment: .bottom) { + HwDeviceIllustrations() + .frame(height: 256) + // Figma keeps a 59.77pt gap between the illustration and the Add button. + .padding(.bottom, 59.77) + .allowsHitTesting(false) } - .frame(maxHeight: .infinity) } /// Stop watching and forget every entry for the device (it may be paired over multiple @@ -115,13 +120,13 @@ private struct HwWalletRow: View { HStack(spacing: 12) { HwConnectionBadge(isConnected: wallet.isConnected) - BodyMText(wallet.name) + BodyMText(wallet.name, textColor: .textPrimary) .lineLimit(1) .frame(maxWidth: .infinity, alignment: .leading) MoneyText( sats: Int(clamping: wallet.balanceSats), - size: .bodySSB, + size: .bodyMSB, symbol: true, color: .white64, symbolColor: .white64 @@ -145,7 +150,7 @@ private struct HwWalletRow: View { .buttonStyle(.plain) .accessibilityIdentifier("HardwareWalletRowDelete_\(wallet.id)") } - .frame(height: 52) + .frame(height: 50) .accessibilityIdentifier("HardwareWalletRow_\(wallet.id)") } } From 778b3295bf683d02da6125d849a78fde99116e48 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 29 Jun 2026 11:28:08 -0300 Subject: [PATCH 3/7] fix: ilustration alignemnt --- .../Views/Settings/General/HardwareWalletsSettingsScreen.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Bitkit/Views/Settings/General/HardwareWalletsSettingsScreen.swift b/Bitkit/Views/Settings/General/HardwareWalletsSettingsScreen.swift index 6fb3073e3..a0bf0e6db 100644 --- a/Bitkit/Views/Settings/General/HardwareWalletsSettingsScreen.swift +++ b/Bitkit/Views/Settings/General/HardwareWalletsSettingsScreen.swift @@ -53,8 +53,6 @@ struct HardwareWalletsSettingsScreen: View { } } - /// Mirrors the hardware intro sheet: staggered device hero filling the top, then an accent title - /// and copy above the Add button. private var emptyState: some View { VStack(spacing: 0) { HwDeviceIllustrations() From c7e266266c230310d4d753263ff17f81cd473663 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 29 Jun 2026 11:29:27 -0300 Subject: [PATCH 4/7] refactor: comments cleanup --- .../Settings/General/HardwareWalletsSettingsScreen.swift | 5 ----- 1 file changed, 5 deletions(-) diff --git a/Bitkit/Views/Settings/General/HardwareWalletsSettingsScreen.swift b/Bitkit/Views/Settings/General/HardwareWalletsSettingsScreen.swift index a0bf0e6db..4f5ab7587 100644 --- a/Bitkit/Views/Settings/General/HardwareWalletsSettingsScreen.swift +++ b/Bitkit/Views/Settings/General/HardwareWalletsSettingsScreen.swift @@ -86,16 +86,11 @@ struct HardwareWalletsSettingsScreen: View { .background(alignment: .bottom) { HwDeviceIllustrations() .frame(height: 256) - // Figma keeps a 59.77pt gap between the illustration and the Add button. .padding(.bottom, 59.77) .allowsHitTesting(false) } } - /// Stop watching and forget every entry for the device (it may be paired over multiple - /// transports). Mirrors `HardwareWalletScreen.removeWallet()`: `removeDevice` stops the watchers - /// and deletes the persisted activities, then `forgetDevice` clears credentials and drops the - /// known-device entry, which re-pushes the snapshot and removes it from the list. private func remove(_ wallet: HwWallet) async { pendingRemoval = nil hwWalletManager.removeDevice(id: wallet.id) From 560bfc256d5cd3a514313329d15eddf6f9d864ed Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 29 Jun 2026 11:49:17 -0300 Subject: [PATCH 5/7] fix: trash button color and item padding --- .../Views/Settings/General/HardwareWalletsSettingsScreen.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Bitkit/Views/Settings/General/HardwareWalletsSettingsScreen.swift b/Bitkit/Views/Settings/General/HardwareWalletsSettingsScreen.swift index 4f5ab7587..f3a23866a 100644 --- a/Bitkit/Views/Settings/General/HardwareWalletsSettingsScreen.swift +++ b/Bitkit/Views/Settings/General/HardwareWalletsSettingsScreen.swift @@ -80,6 +80,7 @@ struct HardwareWalletsSettingsScreen: View { CustomDivider() } } + .padding(.top, 14) .padding(.horizontal, 16) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) @@ -135,7 +136,7 @@ private struct HwWalletRow: View { .resizable() .scaledToFit() .frame(width: 24, height: 24) - .foregroundColor(.white64) + .foregroundColor(.white) .padding(.leading, 12) .padding(.vertical, 8) .contentShape(Rectangle()) From 8fda979cd26dff09fdcf8329a05f7e20bfc564e4 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 29 Jun 2026 12:32:58 -0300 Subject: [PATCH 6/7] feat: add rename feature --- .../Extensions/TrezorDevice+DisplayName.swift | 10 ++- Bitkit/MainNavView.swift | 8 ++ Bitkit/Managers/TrezorManager.swift | 24 ++++++ .../Localization/en.lproj/Localizable.strings | 2 + .../Trezor/TrezorKnownDeviceStorage.swift | 16 +++- Bitkit/ViewModels/SheetViewModel.swift | 14 ++++ .../HardwareWalletsSettingsScreen.swift | 50 +++++++------ .../Sheets/RenameHardwareWalletSheet.swift | 73 +++++++++++++++++++ ...-wallet-settings.added.md => 612.added.md} | 0 9 files changed, 168 insertions(+), 29 deletions(-) create mode 100644 Bitkit/Views/Sheets/RenameHardwareWalletSheet.swift rename changelog.d/next/{hw-wallet-settings.added.md => 612.added.md} (100%) diff --git a/Bitkit/Extensions/TrezorDevice+DisplayName.swift b/Bitkit/Extensions/TrezorDevice+DisplayName.swift index 343816001..bef96a1b4 100644 --- a/Bitkit/Extensions/TrezorDevice+DisplayName.swift +++ b/Bitkit/Extensions/TrezorDevice+DisplayName.swift @@ -1,8 +1,10 @@ import BitkitCore -/// Canonical Trezor display name: the user-set label when it differs from the factory model, -/// otherwise the vendor-prefixed model, falling back to "Trezor". -func resolveHwWalletName(label: String?, model: String?) -> String { +/// Canonical Trezor display name: the Bitkit-side custom name when set, otherwise the device's own +/// label when it differs from the factory model, otherwise the vendor-prefixed model, falling back +/// to "Trezor". +func resolveHwWalletName(label: String?, model: String?, customLabel: String? = nil) -> String { + if let customLabel, !customLabel.isEmpty { return customLabel } if let label, !label.isEmpty, label != model { return label } guard let model else { return "Trezor" } return model.hasPrefix("Trezor") ? model : "Trezor \(model)" @@ -10,7 +12,7 @@ func resolveHwWalletName(label: String?, model: String?) -> String { extension TrezorKnownDevice { var displayName: String { - resolveHwWalletName(label: label, model: model) + resolveHwWalletName(label: label, model: model, customLabel: customLabel) } } diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 55a331a34..efb935581 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -213,6 +213,14 @@ struct MainNavView: View { ) { config in HardwarePairingSheet(config: config) } + .sheet( + item: $sheets.renameHardwareWalletSheetItem, + onDismiss: { + sheets.hideSheet() + } + ) { + config in RenameHardwareWalletSheet(config: config) + } .onChange(of: trezorManager.showPairingCode) { _, needsCode in // A hardware device asked for its one-time pairing code (e.g. during reconnect); // surface the app-wide Pair Device sheet. Hidden again once submitted/cancelled. diff --git a/Bitkit/Managers/TrezorManager.swift b/Bitkit/Managers/TrezorManager.swift index 50f9451a5..f5c6d9a4e 100644 --- a/Bitkit/Managers/TrezorManager.swift +++ b/Bitkit/Managers/TrezorManager.swift @@ -427,6 +427,29 @@ final class TrezorManager { knownDevices = TrezorKnownDeviceStorage.loadAll() } + /// Set the Bitkit-side custom name for a paired device. The name is trimmed and capped; an empty + /// result clears the custom name (falling back to the device label/model). Applies to every stored + /// entry sharing the target's xpub set so the same device renamed over either transport stays + /// consistent, then reloads so the snapshot re-pushes and `HwWallet.name` updates. + func renameDevice(id: String, newName: String) { + let devices = TrezorKnownDeviceStorage.loadAll() + guard let target = devices.first(where: { $0.id == id }) else { return } + + let trimmed = String(newName.trimmingCharacters(in: .whitespacesAndNewlines).prefix(Self.deviceLabelMaxLength)) + let customLabel = trimmed.isEmpty ? nil : trimmed + + let updated = devices.map { device -> TrezorKnownDevice in + let sameGroup = device.id == id || (!target.xpubs.isEmpty && device.xpubs == target.xpubs) + guard sameGroup else { return device } + var copy = device + copy.customLabel = customLabel + return copy + } + TrezorKnownDeviceStorage.saveAll(updated) + loadKnownDevices() + trezorLog("Renamed device \(id) to \(customLabel ?? "")") + } + /// Captures the connected device's account xpubs so watch-only balances/activity stay available /// while disconnected. The watch-only wallet id is derived from the captured xpub set, so a save /// is blocked only when an address type failed *transiently* (a retryable transport error) and @@ -474,6 +497,7 @@ final class TrezorManager { private static let maxXpubFetchAttempts = 3 private static let xpubFetchRetryDelayNanos: UInt64 = 300_000_000 + private static let deviceLabelMaxLength = 50 /// Markers (matched against the underlying `TrezorError` carried in the wrapped error's text) /// for transient transport problems worth retrying. Anything else is treated as the address diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index b6b04a723..9433138f7 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -688,6 +688,8 @@ "settings__general__section_payments" = "Payments"; "settings__hardware_wallets__nav_title" = "Hardware Wallets"; "settings__hardware_wallets__add_button" = "Add Hardware Wallet"; +"settings__hardware_wallets__rename_title" = "Rename Hardware Wallet"; +"settings__hardware_wallets__name_label" = "Name"; "settings__widgets__nav_title" = "Widgets"; "settings__widgets__section_display" = "Display"; "settings__widgets__section_reset" = "Reset To Defaults"; diff --git a/Bitkit/Services/Trezor/TrezorKnownDeviceStorage.swift b/Bitkit/Services/Trezor/TrezorKnownDeviceStorage.swift index 63f22d649..e2407b598 100644 --- a/Bitkit/Services/Trezor/TrezorKnownDeviceStorage.swift +++ b/Bitkit/Services/Trezor/TrezorKnownDeviceStorage.swift @@ -12,6 +12,9 @@ struct TrezorKnownDevice: Codable, Identifiable { /// Account-level extended public keys keyed by `AddressScriptType.stringValue`. /// Persisted so watch-only balances/activity stay available while disconnected. var xpubs: [String: String] + /// User-set name applied while managing the wallet in Bitkit; nil until renamed. Takes priority + /// over the device's own `label`/`model` when resolving the display name. + var customLabel: String? init( id: String, @@ -21,7 +24,8 @@ struct TrezorKnownDevice: Codable, Identifiable { label: String? = nil, model: String? = nil, lastConnectedAt: Date, - xpubs: [String: String] = [:] + xpubs: [String: String] = [:], + customLabel: String? = nil ) { self.id = id self.name = name @@ -31,6 +35,7 @@ struct TrezorKnownDevice: Codable, Identifiable { self.model = model self.lastConnectedAt = lastConnectedAt self.xpubs = xpubs + self.customLabel = customLabel } init(from decoder: any Decoder) throws { @@ -43,6 +48,7 @@ struct TrezorKnownDevice: Codable, Identifiable { model = try container.decodeIfPresent(String.self, forKey: .model) lastConnectedAt = try container.decode(Date.self, forKey: .lastConnectedAt) xpubs = try container.decodeIfPresent([String: String].self, forKey: .xpubs) ?? [:] + customLabel = try container.decodeIfPresent(String.self, forKey: .customLabel) } } @@ -68,6 +74,14 @@ enum TrezorKnownDeviceStorage { } } + /// Persist the full device list as-is. Used for bulk updates (e.g. renaming every entry of a + /// device shared across transports) without per-device reordering. + static func saveAll(_ devices: [TrezorKnownDevice]) { + if let data = try? JSONEncoder().encode(devices) { + UserDefaults.standard.set(data, forKey: key) + } + } + /// Remove a known device by ID static func remove(id: String) { var devices = loadAll() diff --git a/Bitkit/ViewModels/SheetViewModel.swift b/Bitkit/ViewModels/SheetViewModel.swift index f9e53c39c..e3228cbf1 100644 --- a/Bitkit/ViewModels/SheetViewModel.swift +++ b/Bitkit/ViewModels/SheetViewModel.swift @@ -26,6 +26,7 @@ enum SheetID: String, CaseIterable { case widgets case hardwareIntro case hardwarePairing + case renameHardwareWallet } struct SheetConfiguration { @@ -327,6 +328,19 @@ class SheetViewModel: ObservableObject { } } + var renameHardwareWalletSheetItem: RenameHardwareWalletSheetItem? { + get { + guard let config = activeSheetConfiguration, config.id == .renameHardwareWallet else { return nil } + guard let data = config.data as? RenameHardwareWalletConfig else { return nil } + return RenameHardwareWalletSheetItem(deviceId: data.deviceId, currentName: data.currentName) + } + set { + if newValue == nil { + activeSheetConfiguration = nil + } + } + } + var scannerSheetItem: ScannerSheetItem? { get { guard let config = activeSheetConfiguration, config.id == .scanner else { return nil } diff --git a/Bitkit/Views/Settings/General/HardwareWalletsSettingsScreen.swift b/Bitkit/Views/Settings/General/HardwareWalletsSettingsScreen.swift index f3a23866a..6e07cc2be 100644 --- a/Bitkit/Views/Settings/General/HardwareWalletsSettingsScreen.swift +++ b/Bitkit/Views/Settings/General/HardwareWalletsSettingsScreen.swift @@ -7,7 +7,6 @@ import SwiftUI struct HardwareWalletsSettingsScreen: View { @Environment(HwWalletManager.self) private var hwWalletManager @Environment(TrezorManager.self) private var trezorManager - @EnvironmentObject private var navigation: NavigationViewModel @EnvironmentObject private var sheets: SheetViewModel @State private var pendingRemoval: HwWallet? @@ -74,7 +73,12 @@ struct HardwareWalletsSettingsScreen: View { ForEach(wallets) { wallet in HwWalletRow( wallet: wallet, - onTap: { navigation.navigate(.hardwareWallet(deviceId: wallet.id)) }, + onRename: { + sheets.showSheet( + .renameHardwareWallet, + data: RenameHardwareWalletConfig(deviceId: wallet.id, currentName: wallet.name) + ) + }, onRemove: { pendingRemoval = wallet } ) CustomDivider() @@ -101,34 +105,33 @@ struct HardwareWalletsSettingsScreen: View { } } -/// A paired hardware wallet row: connection badge, name, balance and a trailing delete. The badge, -/// name and balance navigate to the device's detail screen; the trash button removes it. +/// A paired hardware wallet row: connection badge, name, balance and a trailing delete. Tapping the +/// name opens the rename sheet; the trash button removes the device. private struct HwWalletRow: View { let wallet: HwWallet - let onTap: () -> Void + let onRename: () -> Void let onRemove: () -> Void var body: some View { - HStack(spacing: 0) { - Button(action: onTap) { - HStack(spacing: 12) { - HwConnectionBadge(isConnected: wallet.isConnected) - - BodyMText(wallet.name, textColor: .textPrimary) - .lineLimit(1) - .frame(maxWidth: .infinity, alignment: .leading) - - MoneyText( - sats: Int(clamping: wallet.balanceSats), - size: .bodyMSB, - symbol: true, - color: .white64, - symbolColor: .white64 - ) - } - .contentShape(Rectangle()) + HStack(spacing: 12) { + HwConnectionBadge(isConnected: wallet.isConnected) + + Button(action: onRename) { + BodyMText(wallet.name, textColor: .textPrimary) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) } .buttonStyle(.plain) + .accessibilityIdentifier("HardwareWalletRowName_\(wallet.id)") + + MoneyText( + sats: Int(clamping: wallet.balanceSats), + size: .bodyMSB, + symbol: true, + color: .white64, + symbolColor: .white64 + ) Button(action: onRemove) { Image("trash") @@ -137,7 +140,6 @@ private struct HwWalletRow: View { .scaledToFit() .frame(width: 24, height: 24) .foregroundColor(.white) - .padding(.leading, 12) .padding(.vertical, 8) .contentShape(Rectangle()) } diff --git a/Bitkit/Views/Sheets/RenameHardwareWalletSheet.swift b/Bitkit/Views/Sheets/RenameHardwareWalletSheet.swift new file mode 100644 index 000000000..d13e4e75a --- /dev/null +++ b/Bitkit/Views/Sheets/RenameHardwareWalletSheet.swift @@ -0,0 +1,73 @@ +import SwiftUI + +struct RenameHardwareWalletConfig { + let deviceId: String + let currentName: String +} + +struct RenameHardwareWalletSheetItem: SheetItem, Equatable { + let id: SheetID = .renameHardwareWallet + let size: SheetSize = .small + let deviceId: String + let currentName: String +} + +/// Renames a paired hardware wallet: a single NAME field pre-filled with the current name and a Save +/// button. Persists the custom name via `TrezorManager.renameDevice`, which re-pushes the device +/// snapshot so `HwWallet.name` updates everywhere. +struct RenameHardwareWalletSheet: View { + @Environment(TrezorManager.self) private var trezorManager + @EnvironmentObject private var sheets: SheetViewModel + + let config: RenameHardwareWalletSheetItem + + @State private var name: String = "" + @FocusState private var isNameFocused: Bool + + private var trimmedName: String { + name.trimmingCharacters(in: .whitespacesAndNewlines) + } + + var body: some View { + Sheet(id: .renameHardwareWallet) { + VStack(alignment: .leading, spacing: 0) { + SheetHeader(title: t("settings__hardware_wallets__rename_title")) + + CaptionMText(t("settings__hardware_wallets__name_label")) + .padding(.bottom, 8) + + TextField( + config.currentName, + text: $name, + testIdentifier: "RenameHardwareWalletInput" + ) + .focused($isNameFocused) + .submitLabel(.done) + .onSubmit(save) + + Spacer(minLength: 16) + + CustomButton( + title: t("common__save"), + isDisabled: trimmedName.isEmpty, + shouldExpand: true + ) { + save() + } + .buttonBottomPadding(isFocused: isNameFocused) + .accessibilityIdentifier("RenameHardwareWalletSave") + } + .padding(.horizontal, 16) + .task { + name = config.currentName + isNameFocused = true + } + } + } + + private func save() { + guard !trimmedName.isEmpty else { return } + trezorManager.renameDevice(id: config.deviceId, newName: trimmedName) + sheets.hideSheet() + } +} diff --git a/changelog.d/next/hw-wallet-settings.added.md b/changelog.d/next/612.added.md similarity index 100% rename from changelog.d/next/hw-wallet-settings.added.md rename to changelog.d/next/612.added.md From 146bee431a6502f0541758ff90bde7e8ca28045c Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 29 Jun 2026 13:59:33 -0300 Subject: [PATCH 7/7] test: add name tests --- BitkitTests/HwWalletNameTests.swift | 21 +++++++++++++++++++++ changelog.d/next/612.added.md | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/BitkitTests/HwWalletNameTests.swift b/BitkitTests/HwWalletNameTests.swift index 11b59bc94..5b2f28fc3 100644 --- a/BitkitTests/HwWalletNameTests.swift +++ b/BitkitTests/HwWalletNameTests.swift @@ -25,4 +25,25 @@ final class HwWalletNameTests: XCTestCase { func testEmptyLabelFallsBackToModel() { XCTAssertEqual(resolveHwWalletName(label: "", model: "Safe 5"), "Trezor Safe 5") } + + func testCustomLabelTakesPriorityOverLabelAndModel() { + XCTAssertEqual( + resolveHwWalletName(label: "My Trezor", model: "Safe 5", customLabel: "Cold Storage"), + "Cold Storage" + ) + } + + func testEmptyCustomLabelFallsBackToLabel() { + XCTAssertEqual( + resolveHwWalletName(label: "My Trezor", model: "Safe 5", customLabel: ""), + "My Trezor" + ) + } + + func testNilCustomLabelFallsBackToPrefixedModel() { + XCTAssertEqual( + resolveHwWalletName(label: nil, model: "Safe 5", customLabel: nil), + "Trezor Safe 5" + ) + } } diff --git a/changelog.d/next/612.added.md b/changelog.d/next/612.added.md index 9b9390f9b..7a438c2b1 100644 --- a/changelog.d/next/612.added.md +++ b/changelog.d/next/612.added.md @@ -1 +1 @@ -Added a Hardware Wallets settings screen, reachable from Settings ▸ General ▸ Payments, to view and remove paired devices. +Added a Hardware Wallets settings screen, reachable from Settings ▸ General ▸ Payments, to view, rename, and remove paired devices.