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 7b959ddc7..c737e481f 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -204,6 +204,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. @@ -531,6 +539,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/Managers/TrezorManager.swift b/Bitkit/Managers/TrezorManager.swift index 729dbc6c2..7805a33be 100644 --- a/Bitkit/Managers/TrezorManager.swift +++ b/Bitkit/Managers/TrezorManager.swift @@ -125,6 +125,17 @@ final class TrezorManager { } .store(in: &cancellables) + // A spontaneous BLE drop (device out of range or phone Bluetooth turned off) + // must clear the live session. + transport.externalDisconnectPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] path in + guard let self, connectedDevice?.path == path else { return } + trezorLog("External disconnect for \(path); clearing session") + clearDisconnectedDeviceState() + } + .store(in: &cancellables) + // Passphrase entry is now driven proactively by the wallet-mode selector // (see setWalletMode / requestPassphraseWallet). The device callback // `onPassphraseRequest` is answered silently from the selected mode, so there @@ -427,6 +438,42 @@ final class TrezorManager { knownDevices = TrezorKnownDeviceStorage.loadAll() } + /// Display name for the currently connected device, applying any Bitkit-side custom rename (from + /// the stored known-device record) over the device's own label/model — mirrors how watch-only + /// tiles resolve names, so a rename shows on the connected-device screen too. + var connectedDeviceDisplayName: String? { + guard let device = connectedDevice else { return nil } + let customLabel = knownDevices.first { $0.id == device.id }?.customLabel + return resolveHwWalletName( + label: device.label ?? deviceFeatures?.label, + model: device.model ?? deviceFeatures?.model, + customLabel: customLabel + ) + } + + /// 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 @@ -464,7 +511,8 @@ final class TrezorManager { label: device.label ?? deviceFeatures?.label, model: device.model ?? deviceFeatures?.model, lastConnectedAt: Date(), - xpubs: mergedXpubs + xpubs: mergedXpubs, + customLabel: previous?.customLabel ) TrezorKnownDeviceStorage.save(known) loadKnownDevices() @@ -474,6 +522,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 b56a60446..9433138f7 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -686,6 +686,10 @@ "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__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/TrezorBLEManager.swift b/Bitkit/Services/Trezor/TrezorBLEManager.swift index 0af190e52..eab25581d 100644 --- a/Bitkit/Services/Trezor/TrezorBLEManager.swift +++ b/Bitkit/Services/Trezor/TrezorBLEManager.swift @@ -1,3 +1,4 @@ +import Combine import CoreBluetooth import Foundation import Observation @@ -47,6 +48,15 @@ class TrezorBLEManager: NSObject { private var connectedPeripheral: CBPeripheral? private var connectedPath: String? + /// Paths whose disconnect was user-initiated (via `disconnect(path:)`), so the + /// spontaneous-disconnect signal is suppressed for deliberate teardowns. + private var expectedDisconnectPaths: Set = [] + + /// Emits the device path when an established connection drops for a reason the + /// user did not request — device out of range or phone Bluetooth turned off. + /// `TrezorManager` subscribes (via `TrezorTransport`) to clear the stale session. + let externalDisconnectPublisher = PassthroughSubject() + /// GATT characteristics private var writeCharacteristic: CBCharacteristic? private var notifyCharacteristic: CBCharacteristic? @@ -284,6 +294,10 @@ class TrezorBLEManager: NSObject { // Clean up any stale connection state (preserves discoveredPeripherals cache) cleanupConnectionState() + // Drop any stale deliberate-disconnect marker from a prior session so it can't + // suppress a future spontaneous drop on this reused path. + expectedDisconnectPaths.remove(path) + debugLog("connectOnce: \(path)") // Try to get peripheral from cache first @@ -437,6 +451,10 @@ class TrezorBLEManager: NSObject { debugLog("Disconnecting: \(path)") + // Mark this as a deliberate teardown so the resulting didDisconnect callback + // does not surface as a spontaneous (out-of-range) drop. + expectedDisconnectPaths.insert(path) + if let notifyChar = notifyCharacteristic { peripheral.setNotifyValue(false, for: notifyChar) } @@ -550,6 +568,20 @@ extension TrezorBLEManager: CBCentralManagerDelegate { self.isScanning = false } } + + // Bluetooth turned off / unauthorized invalidates any live connection. iOS does + // not guarantee a per-peripheral didDisconnect on power-off, so treat it as a + // spontaneous drop here: clear internal state and surface the disconnect. + if central.state != .poweredOn, let path = connectedPath { + connectedPeripheral = nil + connectedPath = nil + writeCharacteristic = nil + notifyCharacteristic = nil + readQueue.clear() + if expectedDisconnectPaths.remove(path) == nil { + externalDisconnectPublisher.send(path) + } + } } func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, @@ -592,7 +624,11 @@ extension TrezorBLEManager: CBCentralManagerDelegate { takeConnectContinuation()?.resume(throwing: error ?? TrezorBLEError.connectionFailed) } - func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { + func centralManager( + _ central: CBCentralManager, + didDisconnectPeripheral peripheral: CBPeripheral, + error: Error? + ) { debugLog("didDisconnect: \(peripheral.identifier)\(error.map { " error: \($0.localizedDescription)" } ?? "")") let disconnectError = error ?? TrezorBLEError.connectionFailed @@ -603,12 +639,23 @@ extension TrezorBLEManager: CBCentralManagerDelegate { takeNotificationContinuation()?.resume(throwing: disconnectError) takeWriteContinuation()?.resume(throwing: disconnectError) + let path = "ble:\(peripheral.identifier.uuidString)" + // Consume any deliberate-disconnect marker even when disconnect(path:) already + // cleared connection state, so it can't be mistaken for a future real drop. + let wasExpected = expectedDisconnectPaths.remove(path) != nil + if connectedPeripheral?.identifier == peripheral.identifier { connectedPeripheral = nil connectedPath = nil writeCharacteristic = nil notifyCharacteristic = nil readQueue.clear() + + // Surface a spontaneous drop (out of range) so the session can be cleared. + // A deliberate disconnect(path:) is suppressed via expectedDisconnectPaths. + if !wasExpected { + externalDisconnectPublisher.send(path) + } } } } 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/Services/Trezor/TrezorTransport.swift b/Bitkit/Services/Trezor/TrezorTransport.swift index 4649c718f..b143c488a 100644 --- a/Bitkit/Services/Trezor/TrezorTransport.swift +++ b/Bitkit/Services/Trezor/TrezorTransport.swift @@ -305,6 +305,12 @@ final class TrezorTransport: TrezorTransportCallback { bleManager.bluetoothState } + /// Emits a device path when an established BLE connection drops unexpectedly + /// (out of range or phone Bluetooth turned off). + var externalDisconnectPublisher: PassthroughSubject { + bleManager.externalDisconnectPublisher + } + var isBridgeEnabled: Bool { bridgeTransport.isEnabled } 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/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 new file mode 100644 index 000000000..6e07cc2be --- /dev/null +++ b/Bitkit/Views/Settings/General/HardwareWalletsSettingsScreen.swift @@ -0,0 +1,194 @@ +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 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 + @EnvironmentObject private var sheets: SheetViewModel + + @State private var pendingRemoval: HwWallet? + + private var wallets: [HwWallet] { + hwWalletManager.wallets + } + + var body: some View { + VStack(spacing: 0) { + NavigationBar(title: t("settings__hardware_wallets__nav_title")) + .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") + .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(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) + } + } + + private var deviceList: some View { + ScrollView(showsIndicators: false) { + LazyVStack(spacing: 0) { + ForEach(wallets) { wallet in + HwWalletRow( + wallet: wallet, + onRename: { + sheets.showSheet( + .renameHardwareWallet, + data: RenameHardwareWalletConfig(deviceId: wallet.id, currentName: wallet.name) + ) + }, + onRemove: { pendingRemoval = wallet } + ) + CustomDivider() + } + } + .padding(.top, 14) + .padding(.horizontal, 16) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(alignment: .bottom) { + HwDeviceIllustrations() + .frame(height: 256) + .padding(.bottom, 59.77) + .allowsHitTesting(false) + } + } + + 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. Tapping the +/// name opens the rename sheet; the trash button removes the device. +private struct HwWalletRow: View { + let wallet: HwWallet + let onRename: () -> Void + let onRemove: () -> Void + + var body: some View { + 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") + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + .foregroundColor(.white) + .padding(.vertical, 8) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier("HardwareWalletRowDelete_\(wallet.id)") + } + .frame(height: 50) + .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/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/Bitkit/Views/Trezor/TrezorConnectedView.swift b/Bitkit/Views/Trezor/TrezorConnectedView.swift index 35426a3d4..270c5d402 100644 --- a/Bitkit/Views/Trezor/TrezorConnectedView.swift +++ b/Bitkit/Views/Trezor/TrezorConnectedView.swift @@ -19,7 +19,7 @@ struct TrezorConnectedView: View { VStack(spacing: 24) { // Device card DeviceInfoCard( - device: trezorManager.connectedDevice, + name: trezorManager.connectedDeviceDisplayName ?? "Trezor", features: trezorManager.deviceFeatures ) @@ -143,7 +143,7 @@ struct TrezorConnectedView: View { ToolbarItem(placement: .navigationBarTrailing) { TrezorStatusBadge( isConnected: trezorManager.isConnected, - deviceName: trezorManager.deviceFeatures?.label + deviceName: trezorManager.connectedDeviceDisplayName ) .allowsHitTesting(false) } @@ -202,7 +202,7 @@ private struct WalletModeSelectorRow: View { // MARK: - Device Info Card private struct DeviceInfoCard: View { - let device: TrezorDeviceInfo? + let name: String let features: TrezorFeatures? var body: some View { @@ -217,7 +217,7 @@ private struct DeviceInfoCard: View { // Device name VStack(spacing: 4) { - Text(features?.label ?? "Trezor") + Text(name) .font(.system(size: 20, weight: .bold)) .foregroundColor(.white) 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 new file mode 100644 index 000000000..7a438c2b1 --- /dev/null +++ b/changelog.d/next/612.added.md @@ -0,0 +1 @@ +Added a Hardware Wallets settings screen, reachable from Settings ▸ General ▸ Payments, to view, rename, and remove paired devices.