diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj
index b967576a4..a1a39b9ac 100644
--- a/Bitkit.xcodeproj/project.pbxproj
+++ b/Bitkit.xcodeproj/project.pbxproj
@@ -1201,7 +1201,7 @@
repositoryURL = "https://github.com/synonymdev/bitkit-core";
requirement = {
kind = exactVersion;
- version = 0.3.6;
+ version = 0.3.9;
};
};
96E20CD22CB6D91A00C24149 /* XCRemoteSwiftPackageReference "CodeScanner" */ = {
diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
index af46a1381..a418ff0d1 100644
--- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
+++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -6,8 +6,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/synonymdev/bitkit-core",
"state" : {
- "revision" : "89632fe36d73c33ac5c783ee9560e211da3685cd",
- "version" : "0.3.6"
+ "revision" : "5e08294bc5701d8b48337745990a417841d6e026",
+ "version" : "0.3.9"
}
},
{
diff --git a/Bitkit/Assets.xcassets/Illustrations/coin-stack-3.imageset/Contents.json b/Bitkit/Assets.xcassets/Illustrations/coin-stack-3.imageset/Contents.json
new file mode 100644
index 000000000..311bf5850
--- /dev/null
+++ b/Bitkit/Assets.xcassets/Illustrations/coin-stack-3.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "coin-stack-3.png",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Bitkit/Assets.xcassets/Illustrations/coin-stack-3.imageset/coin-stack-3.png b/Bitkit/Assets.xcassets/Illustrations/coin-stack-3.imageset/coin-stack-3.png
new file mode 100644
index 000000000..98c058f10
Binary files /dev/null and b/Bitkit/Assets.xcassets/Illustrations/coin-stack-3.imageset/coin-stack-3.png differ
diff --git a/Bitkit/Assets.xcassets/Illustrations/hw-searching-arrows.imageset/Contents.json b/Bitkit/Assets.xcassets/Illustrations/hw-searching-arrows.imageset/Contents.json
new file mode 100644
index 000000000..a40a89637
--- /dev/null
+++ b/Bitkit/Assets.xcassets/Illustrations/hw-searching-arrows.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "hw-searching-arrows.png",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Bitkit/Assets.xcassets/Illustrations/hw-searching-arrows.imageset/hw-searching-arrows.png b/Bitkit/Assets.xcassets/Illustrations/hw-searching-arrows.imageset/hw-searching-arrows.png
new file mode 100644
index 000000000..eced4a766
Binary files /dev/null and b/Bitkit/Assets.xcassets/Illustrations/hw-searching-arrows.imageset/hw-searching-arrows.png differ
diff --git a/Bitkit/Assets.xcassets/Illustrations/hw-searching-ring-inner.imageset/Contents.json b/Bitkit/Assets.xcassets/Illustrations/hw-searching-ring-inner.imageset/Contents.json
new file mode 100644
index 000000000..0bf65fca2
--- /dev/null
+++ b/Bitkit/Assets.xcassets/Illustrations/hw-searching-ring-inner.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "hw-searching-ring-inner.png",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Bitkit/Assets.xcassets/Illustrations/hw-searching-ring-inner.imageset/hw-searching-ring-inner.png b/Bitkit/Assets.xcassets/Illustrations/hw-searching-ring-inner.imageset/hw-searching-ring-inner.png
new file mode 100644
index 000000000..8d94f0249
Binary files /dev/null and b/Bitkit/Assets.xcassets/Illustrations/hw-searching-ring-inner.imageset/hw-searching-ring-inner.png differ
diff --git a/Bitkit/Assets.xcassets/Illustrations/hw-searching-ring.imageset/Contents.json b/Bitkit/Assets.xcassets/Illustrations/hw-searching-ring.imageset/Contents.json
new file mode 100644
index 000000000..c1f6478dd
--- /dev/null
+++ b/Bitkit/Assets.xcassets/Illustrations/hw-searching-ring.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "hw-searching-ring.png",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Bitkit/Assets.xcassets/Illustrations/hw-searching-ring.imageset/hw-searching-ring.png b/Bitkit/Assets.xcassets/Illustrations/hw-searching-ring.imageset/hw-searching-ring.png
new file mode 100644
index 000000000..8e30fa63d
Binary files /dev/null and b/Bitkit/Assets.xcassets/Illustrations/hw-searching-ring.imageset/hw-searching-ring.png differ
diff --git a/Bitkit/Assets.xcassets/Illustrations/trezor.imageset/Contents.json b/Bitkit/Assets.xcassets/Illustrations/trezor-cropped.imageset/Contents.json
similarity index 76%
rename from Bitkit/Assets.xcassets/Illustrations/trezor.imageset/Contents.json
rename to Bitkit/Assets.xcassets/Illustrations/trezor-cropped.imageset/Contents.json
index 012c311e7..fdd8d4517 100644
--- a/Bitkit/Assets.xcassets/Illustrations/trezor.imageset/Contents.json
+++ b/Bitkit/Assets.xcassets/Illustrations/trezor-cropped.imageset/Contents.json
@@ -1,12 +1,12 @@
{
"images" : [
{
- "filename" : "trezor.png",
+ "filename" : "trezor-cropped.png",
"idiom" : "universal",
"scale" : "1x"
},
{
- "filename" : "trezor@2x.png",
+ "filename" : "trezor-cropped@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
diff --git a/Bitkit/Assets.xcassets/Illustrations/trezor.imageset/trezor.png b/Bitkit/Assets.xcassets/Illustrations/trezor-cropped.imageset/trezor-cropped.png
similarity index 100%
rename from Bitkit/Assets.xcassets/Illustrations/trezor.imageset/trezor.png
rename to Bitkit/Assets.xcassets/Illustrations/trezor-cropped.imageset/trezor-cropped.png
diff --git a/Bitkit/Assets.xcassets/Illustrations/trezor.imageset/trezor@2x.png b/Bitkit/Assets.xcassets/Illustrations/trezor-cropped.imageset/trezor-cropped@2x.png
similarity index 100%
rename from Bitkit/Assets.xcassets/Illustrations/trezor.imageset/trezor@2x.png
rename to Bitkit/Assets.xcassets/Illustrations/trezor-cropped.imageset/trezor-cropped@2x.png
diff --git a/Bitkit/Components/Trezor/HwDeviceIllustrations.swift b/Bitkit/Components/Trezor/HwDeviceIllustrations.swift
index 513fc381d..03be9993b 100644
--- a/Bitkit/Components/Trezor/HwDeviceIllustrations.swift
+++ b/Bitkit/Components/Trezor/HwDeviceIllustrations.swift
@@ -24,7 +24,7 @@ struct HwDeviceIllustrations: View {
let staggerY = width * Layout.proportionalStagger
ZStack {
- Image("trezor")
+ Image("trezor-cropped")
.resizable()
.scaledToFit()
.frame(width: width * Layout.trezorProportionalWidth, height: imageHeight)
diff --git a/Bitkit/Components/Widgets/Suggestions.swift b/Bitkit/Components/Widgets/Suggestions.swift
index 779f0bb13..592710083 100644
--- a/Bitkit/Components/Widgets/Suggestions.swift
+++ b/Bitkit/Components/Widgets/Suggestions.swift
@@ -358,7 +358,7 @@ struct Suggestions: View {
case .support:
route = .support
case .hardware:
- sheets.showSheet(.hardwareIntro)
+ sheets.showSheet(.hardwareConnect)
case .transferToSpending:
route = app.hasSeenTransferIntro ? .fundingOptions : .transferIntro
}
diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift
index c737e481f..e41a788f2 100644
--- a/Bitkit/MainNavView.swift
+++ b/Bitkit/MainNavView.swift
@@ -189,12 +189,12 @@ struct MainNavView: View {
config in WidgetsSheet(config: config)
}
.sheet(
- item: $sheets.hardwareIntroSheetItem,
+ item: $sheets.hardwareConnectSheetItem,
onDismiss: {
sheets.hideSheet()
}
) {
- config in HardwareIntroSheet(config: config)
+ config in HardwareConnectSheet(config: config)
}
.sheet(
item: $sheets.hardwarePairingSheetItem,
@@ -216,6 +216,7 @@ struct MainNavView: View {
// 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.
if needsCode {
+ guard !sheets.hardwareConnectHandlesPairing else { return }
sheets.showSheet(.hardwarePairing)
} else {
sheets.hideSheetIfActive(.hardwarePairing, reason: "Pairing code resolved")
diff --git a/Bitkit/Managers/TrezorManager.swift b/Bitkit/Managers/TrezorManager.swift
index 7805a33be..845d40975 100644
--- a/Bitkit/Managers/TrezorManager.swift
+++ b/Bitkit/Managers/TrezorManager.swift
@@ -52,6 +52,8 @@ final class TrezorManager {
var showPairingCode: Bool = false
+ private(set) var pairingCodeRequestID: Int = 0
+
var showConfirmOnDevice: Bool = false
var confirmMessage: String = ""
@@ -115,6 +117,7 @@ final class TrezorManager {
.receive(on: DispatchQueue.main)
.sink { [weak self] in
self?.showPairingCode = true
+ self?.pairingCodeRequestID &+= 1
}
.store(in: &cancellables)
@@ -170,6 +173,7 @@ final class TrezorManager {
showPinEntry = false
showPassphraseEntry = false
showConfirmOnDevice = false
+ showPairingCode = false
showWalletModeChooser = false
uiHandler.setWalletMode(.standard)
walletMode = .standard
@@ -263,6 +267,7 @@ final class TrezorManager {
func connect(device: TrezorDeviceInfo) async {
error = nil
suppressNextAutoReconnect = false
+ showPairingCode = false
// Explicit user-initiated connect always opens the standard wallet — a
// passphrase/on-device selection left over from a previously connected device
@@ -274,6 +279,13 @@ final class TrezorManager {
do {
let features = try await trezorService.connect(deviceId: device.path, selection: uiHandler.currentSelection())
+
+ if Task.isCancelled {
+ try? await trezorService.disconnect()
+ trezorLog("Connect cancelled before pairing; disconnected \(device.path)")
+ return
+ }
+
connectedDevice = device
deviceFeatures = features
showConfirmOnDevice = false
@@ -710,6 +722,12 @@ final class TrezorManager {
if message.contains("Pairing required") {
return "Bluetooth pairing required. Please put your Trezor in pairing mode."
}
+ if message.contains("Code verification failed") || message.contains("verification failed") {
+ return t("hardware__pairing_code_invalid")
+ }
+ if message.contains("DeviceBusy") || message.contains("Device is busy") || message.contains("DeviceLocked") {
+ return t("hardware__device_busy")
+ }
if message.contains("Pairing failed") || message.contains("Invalid credentials") {
return "Pairing failed. Please try putting your Trezor back in pairing mode."
}
diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings
index 9433138f7..4eaefe66c 100644
--- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings
@@ -36,6 +36,31 @@
"hardware__intro_title" = "Hardware Wallet";
"hardware__intro_header" = "Add your hardware wallet";
"hardware__intro_text" = "Connect your hardware device to watch or manage your long-term funds.";
+"hardware__connect_title" = "Connect Device";
+"hardware__connect_header" = "Searching for devices";
+"hardware__connect_text" = "Please connect your hardware wallet now via Bluetooth.";
+"hardware__connect_error" = "Could not connect to your Trezor. Check that it is unlocked and try again.";
+"hardware__search_error" = "Could not search for hardware wallets. Check your connection and try again.";
+"hardware__pairing_code_invalid" = "Incorrect pairing code. Put your Trezor back in pairing mode and try again.";
+"hardware__device_busy" = "Your Trezor is busy. Unlock it on the device, then try again.";
+"hardware__device_model_trezor" = "Trezor";
+"hardware__bluetooth_open_settings" = "Open Settings";
+"hardware__bluetooth_off_title" = "Bluetooth is Off";
+"hardware__bluetooth_off_text" = "Please enable Bluetooth in Settings to connect to your Trezor.";
+"hardware__bluetooth_unauthorized_title" = "Bluetooth Unauthorized";
+"hardware__bluetooth_unauthorized_text" = "Bitkit needs Bluetooth permission to connect to your Trezor. Please enable it in Settings.";
+"hardware__bluetooth_unsupported_title" = "Bluetooth Unsupported";
+"hardware__bluetooth_unsupported_text" = "This device does not support Bluetooth Low Energy.";
+"hardware__bluetooth_unavailable_title" = "Bluetooth Unavailable";
+"hardware__bluetooth_unavailable_text" = "Bluetooth is not available. Please check your device settings.";
+"hardware__found_title" = "Found Device";
+"hardware__found_header" = "Found\nTrezor";
+"hardware__found_text" = "Would you like to securely pair this {model} with Bitkit?";
+"hardware__paired_title" = "Device Connected";
+"hardware__paired_header" = "Paired\nTrezor";
+"hardware__paired_text" = "Bitkit found funds on your device and added these to your balance.";
+"hardware__paired_label" = "Label Funds";
+"hardware__paired_finish" = "Finish";
"hardware__pairing_title" = "Pair Device";
"hardware__pairing_text" = "Enter the 6-digit code shown on your hardware device.";
"hardware__remove_button" = "Remove {name}";
diff --git a/Bitkit/Services/Trezor/TrezorTransport.swift b/Bitkit/Services/Trezor/TrezorTransport.swift
index b143c488a..342d5a6ca 100644
--- a/Bitkit/Services/Trezor/TrezorTransport.swift
+++ b/Bitkit/Services/Trezor/TrezorTransport.swift
@@ -198,6 +198,10 @@ final class TrezorTransport: TrezorTransportCallback {
submittedPairingCode = ""
pairingCodeLock.unlock()
+ // Drain any leftover signal from a prior (cancelled/submitted) pairing so this request
+ // doesn't return an empty code instantly and abort the handshake mid-pairing.
+ while pairingCodeSemaphore.wait(timeout: DispatchTime.now()) == .success {}
+
// Notify UI to show pairing code dialog
DispatchQueue.main.async {
self.needsPairingCodePublisher.send()
diff --git a/Bitkit/ViewModels/SheetViewModel.swift b/Bitkit/ViewModels/SheetViewModel.swift
index e3228cbf1..64ebe3d90 100644
--- a/Bitkit/ViewModels/SheetViewModel.swift
+++ b/Bitkit/ViewModels/SheetViewModel.swift
@@ -24,7 +24,7 @@ enum SheetID: String, CaseIterable {
case tagFilter
case dateRangeSelector
case widgets
- case hardwareIntro
+ case hardwareConnect
case hardwarePairing
case renameHardwareWallet
}
@@ -36,6 +36,7 @@ struct SheetConfiguration {
class SheetViewModel: ObservableObject {
@Published var activeSheetConfiguration: SheetConfiguration? = nil
+ @Published var hardwareConnectHandlesPairing = false
func showSheet(_ id: SheetID, data: Any? = nil) {
if isAnySheetOpen {
@@ -304,10 +305,10 @@ class SheetViewModel: ObservableObject {
}
}
- var hardwareIntroSheetItem: HardwareIntroSheetItem? {
+ var hardwareConnectSheetItem: HardwareConnectSheetItem? {
get {
- guard let config = activeSheetConfiguration, config.id == .hardwareIntro else { return nil }
- return HardwareIntroSheetItem()
+ guard let config = activeSheetConfiguration, config.id == .hardwareConnect else { return nil }
+ return HardwareConnectSheetItem()
}
set {
if newValue == nil {
diff --git a/Bitkit/ViewModels/Trezor/HwConnectViewModel.swift b/Bitkit/ViewModels/Trezor/HwConnectViewModel.swift
new file mode 100644
index 000000000..038da78cd
--- /dev/null
+++ b/Bitkit/ViewModels/Trezor/HwConnectViewModel.swift
@@ -0,0 +1,237 @@
+import BitkitCore
+import Foundation
+
+/// Result of a successful hardware-wallet connect: the persisted known-device id and its resolved
+/// display name (from the device's own label/model).
+struct HwConnectResult: Equatable {
+ let deviceId: String
+ let name: String
+}
+
+/// Device discovery/connection seam the Connect Hardware flow drives. `TrezorHwConnectService` is
+/// the production adapter over `TrezorManager`; tests inject a fake so the flow can be exercised
+/// without the BLE stack.
+@MainActor
+protocol HwConnectServicing {
+ func scanForUnpairedDevices() async throws -> [TrezorDeviceInfo]
+ func connect(to device: TrezorDeviceInfo) async throws -> HwConnectResult
+ func setDeviceLabel(id: String, label: String)
+ func cancelPairingCode()
+}
+
+/// Backs the Connect Hardware bottom-sheet flow (Intro → Searching → Found → Paired). Drives device
+/// discovery, connection and the Bitkit-side funds label through an `HwConnectServicing`, exposing a
+/// single `phase` the sheet renders. The one-time pairing code, when the device requests it during
+/// connect, is surfaced inline by moving to `.pairCode`. Reactivity to `showPairingCode`/`wallets`
+/// lives in the sheet (idiomatic `.onChange`), which forwards changes via `onPairingCodeRequested()`
+/// / `onWalletsUpdated(_:)`.
+@Observable
+@MainActor
+final class HwConnectViewModel {
+ enum Phase: Hashable {
+ case intro
+ case searching
+ case found
+ case paired
+ case pairCode
+ }
+
+ static let deviceLabelMaxLength = 50
+ private static let scanInterval: Duration = .seconds(2)
+
+ // MARK: - Published state
+
+ private(set) var phase: Phase = .intro
+ private(set) var isConnecting = false
+ private(set) var foundDevice: TrezorDeviceInfo?
+ private(set) var foundDeviceModel = ""
+ private(set) var pairedDeviceId: String?
+ private(set) var deviceName = ""
+ private(set) var balanceSats: UInt64 = 0
+ private(set) var labelInput = ""
+ private(set) var errorMessage: String?
+
+ /// Invoked when the user taps Finish after the label is persisted, so the host can dismiss the
+ /// sheet and return to Home. Set by the sheet.
+ var onFinished: (() -> Void)?
+
+ // MARK: - Dependencies & internal state
+
+ private let service: HwConnectServicing
+ private var labelInitialized = false
+ private var searchTask: Task?
+ private var connectTask: Task?
+
+ init(service: HwConnectServicing) {
+ self.service = service
+ }
+
+ // MARK: - Intro → Searching
+
+ func onIntroContinue() {
+ errorMessage = nil
+ phase = .searching
+ startSearching()
+ }
+
+ private func startSearching() {
+ guard searchTask == nil else { return }
+ errorMessage = nil
+ searchTask = Task { [weak self] in
+ while let self, !Task.isCancelled {
+ do {
+ let devices = try await service.scanForUnpairedDevices()
+ if Task.isCancelled { return }
+ errorMessage = nil
+ if let device = devices.first {
+ onDeviceFound(device)
+ return
+ }
+ } catch {
+ if Task.isCancelled { return }
+ errorMessage = t("hardware__search_error")
+ }
+ do {
+ try await Task.sleep(for: Self.scanInterval)
+ } catch {
+ return
+ }
+ }
+ }
+ }
+
+ private func onDeviceFound(_ device: TrezorDeviceInfo) {
+ searchTask?.cancel()
+ searchTask = nil
+ foundDevice = device
+ foundDeviceModel = resolveHwWalletName(label: nil, model: device.model)
+ errorMessage = nil
+ phase = .found
+ }
+
+ // MARK: - Found → Connect → Paired
+
+ func onConnect() {
+ guard let device = foundDevice, connectTask == nil else { return }
+ searchTask?.cancel()
+ searchTask = nil
+ isConnecting = true
+ errorMessage = nil
+ connectTask = Task { [weak self] in
+ guard let self else { return }
+ do {
+ let result = try await service.connect(to: device)
+ if Task.isCancelled { return }
+ onConnected(result)
+ } catch {
+ if Task.isCancelled { return }
+ onConnectFailed(error)
+ }
+ connectTask = nil
+ }
+ }
+
+ private func onConnected(_ result: HwConnectResult) {
+ isConnecting = false
+ pairedDeviceId = result.deviceId
+ deviceName = result.name
+ if !labelInitialized {
+ labelInput = result.name
+ }
+ labelInitialized = true
+ errorMessage = nil
+ phase = .paired
+ }
+
+ private func onConnectFailed(_ error: Error) {
+ isConnecting = false
+ errorMessage = (error as? AppError)?.message ?? t("hardware__connect_error")
+ phase = .found
+ }
+
+ /// The device asked for its one-time pairing code mid-connect; surface the inline step. Only
+ /// while a connect is in flight, so a stray flag can't hijack the flow.
+ func onPairingCodeRequested() {
+ guard isConnecting else { return }
+ phase = .pairCode
+ }
+
+ // MARK: - Paired
+
+ /// The connected wallet's aggregated balance/name landed; reflect it on the Paired step.
+ func onWalletsUpdated(_ wallets: [HwWallet]) {
+ guard let deviceId = pairedDeviceId else { return }
+ guard let wallet = wallets.first(where: { $0.id == deviceId || $0.deviceIds.contains(deviceId) }) else { return }
+ deviceName = wallet.name
+ balanceSats = wallet.balanceSats
+ if !labelInitialized {
+ labelInput = wallet.name
+ }
+ labelInitialized = true
+ }
+
+ func onLabelChange(_ value: String) {
+ labelInput = String(value.prefix(Self.deviceLabelMaxLength))
+ }
+
+ func onFinish() {
+ if let deviceId = pairedDeviceId {
+ service.setDeviceLabel(id: deviceId, label: labelInput)
+ }
+ onFinished?()
+ }
+
+ // MARK: - Teardown
+
+ /// Cancels a pending connect/pairing-code request when the user backs out mid-connect.
+ func cancelConnect() {
+ connectTask?.cancel()
+ connectTask = nil
+ service.cancelPairingCode()
+ isConnecting = false
+ }
+
+ /// Called when the sheet is dismissed: stop scanning/connecting and drop any pending pairing.
+ func reset() {
+ searchTask?.cancel()
+ searchTask = nil
+ cancelConnect()
+ }
+}
+
+/// Production `HwConnectServicing` over `TrezorManager`. iOS is BLE-only, so discovery is a single
+/// BLE scan filtered to unpaired devices; `connect(to:)` reports success by inspecting the manager's
+/// `connectedDevice`/`deviceFeatures` (its own `connect` returns void and stores state) and surfaces
+/// the manager's error otherwise.
+@MainActor
+struct TrezorHwConnectService: HwConnectServicing {
+ let trezorManager: TrezorManager
+
+ func scanForUnpairedDevices() async throws -> [TrezorDeviceInfo] {
+ await trezorManager.startScan()
+ if let error = trezorManager.error {
+ throw AppError(message: error, debugMessage: nil)
+ }
+ return trezorManager.devices.filter { !TrezorKnownDeviceStorage.isKnown(id: $0.id) }
+ }
+
+ func connect(to device: TrezorDeviceInfo) async throws -> HwConnectResult {
+ await trezorManager.connect(device: device)
+ guard let connected = trezorManager.connectedDevice, connected.id == device.id else {
+ throw AppError(message: trezorManager.error ?? t("hardware__connect_error"), debugMessage: nil)
+ }
+ let name = resolveHwWalletName(
+ label: connected.label ?? trezorManager.deviceFeatures?.label,
+ model: connected.model ?? trezorManager.deviceFeatures?.model
+ )
+ return HwConnectResult(deviceId: connected.id, name: name)
+ }
+
+ func setDeviceLabel(id: String, label: String) {
+ trezorManager.renameDevice(id: id, newName: label)
+ }
+
+ func cancelPairingCode() {
+ trezorManager.cancelPairingCode()
+ }
+}
diff --git a/Bitkit/Views/Settings/General/HardwareWalletsSettingsScreen.swift b/Bitkit/Views/Settings/General/HardwareWalletsSettingsScreen.swift
index 6e07cc2be..58e9b0719 100644
--- a/Bitkit/Views/Settings/General/HardwareWalletsSettingsScreen.swift
+++ b/Bitkit/Views/Settings/General/HardwareWalletsSettingsScreen.swift
@@ -30,7 +30,7 @@ struct HardwareWalletsSettingsScreen: View {
title: t("settings__hardware_wallets__add_button"),
shouldExpand: true
) {
- sheets.showSheet(.hardwareIntro)
+ sheets.showSheet(.hardwareConnect)
}
.accessibilityIdentifier("AddHardwareWallet")
.padding(.horizontal, 16)
diff --git a/Bitkit/Views/Sheets/HardwareConnect/HwFoundView.swift b/Bitkit/Views/Sheets/HardwareConnect/HwFoundView.swift
new file mode 100644
index 000000000..12fa50e9a
--- /dev/null
+++ b/Bitkit/Views/Sheets/HardwareConnect/HwFoundView.swift
@@ -0,0 +1,65 @@
+import SwiftUI
+
+/// Found step: a discovered device with a Connect confirmation. Connect shows a spinner and
+/// surfaces an inline error on failure.
+struct HwFoundView: View {
+ let deviceModel: String
+ let isConnecting: Bool
+ let errorMessage: String?
+ let onConnect: () -> Void
+ let onCancel: () -> Void
+
+ var body: some View {
+ VStack(spacing: 0) {
+ SheetHeader(title: t("hardware__found_title"))
+ .padding(.horizontal, 16)
+
+ VStack(alignment: .leading, spacing: 8) {
+ DisplayText(t("hardware__found_header"), accentColor: .blueAccent)
+
+ BodyMText(t("hardware__found_text", variables: ["model": deviceModel]))
+
+ if let errorMessage {
+ BodyMText(errorMessage, textColor: .redAccent)
+ .padding(.top, 8)
+ .accessibilityIdentifier("HwFoundError")
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 32)
+
+ Image("trezor-device")
+ .resizable()
+ .scaledToFit()
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .padding(.vertical, 24)
+
+ HStack(spacing: 16) {
+ CustomButton(title: t("common__cancel"), variant: .secondary, shouldExpand: true) {
+ onCancel()
+ }
+ .accessibilityIdentifier("HwFoundCancel")
+
+ CustomButton(
+ title: t("common__connect"),
+ isDisabled: isConnecting,
+ isLoading: isConnecting,
+ shouldExpand: true
+ ) {
+ onConnect()
+ }
+ .accessibilityIdentifier("HwFoundConnect")
+ }
+ .padding(.horizontal, 32)
+ .padding(.bottom, 16)
+ }
+ .accessibilityIdentifier("HardwareWalletFoundScreen")
+ }
+}
+
+#Preview {
+ HwFoundView(deviceModel: "Trezor Safe 3", isConnecting: false, errorMessage: nil, onConnect: {}, onCancel: {})
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(Color.black)
+ .preferredColorScheme(.dark)
+}
diff --git a/Bitkit/Views/Sheets/HardwareConnect/HwPairCodeView.swift b/Bitkit/Views/Sheets/HardwareConnect/HwPairCodeView.swift
new file mode 100644
index 000000000..6b3c3062b
--- /dev/null
+++ b/Bitkit/Views/Sheets/HardwareConnect/HwPairCodeView.swift
@@ -0,0 +1,95 @@
+import SwiftUI
+
+/// Inline one-time THP pairing-code step shown when a device asks for its 6-digit code mid-connect.
+/// On the sixth digit the code row collapses into a spinner while pairing completes.
+struct HwPairCodeView: View {
+ let onSubmit: (String) -> Void
+
+ private let codeLength = 6
+ private let cellWidth: CGFloat = 32
+ private let cellSpacing: CGFloat = 8
+
+ @State private var code = ""
+ @State private var submitted = false
+
+ private var progress: CGFloat {
+ submitted ? 1 : 0
+ }
+
+ private var cellStep: CGFloat {
+ cellWidth + cellSpacing
+ }
+
+ var body: some View {
+ VStack(spacing: 0) {
+ SheetHeader(title: t("hardware__pairing_title"))
+ .padding(.horizontal, 16)
+
+ VStack(spacing: 0) {
+ BodyMText(t("hardware__pairing_text"))
+ .multilineTextAlignment(.center)
+
+ Spacer()
+
+ codeRow
+ .overlay {
+ ProgressView()
+ .progressViewStyle(CircularProgressViewStyle(tint: .brandAccent))
+ .frame(width: 32, height: 32)
+ .opacity(progress)
+ .scaleEffect(0.8 + 0.2 * progress)
+ }
+
+ Spacer()
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .padding(.horizontal, 32)
+
+ NumberPad(type: .simple, isDisabled: submitted, onPress: handleKey)
+ .frame(height: NumberPad.contentHeight)
+ }
+ .accessibilityIdentifier("HardwareWalletPairCodeScreen")
+ }
+
+ /// Fixed-width cells so digits replace dots without the row shifting; on submit each cell
+ /// collapses toward the center and fades so the spinner can take its place.
+ private var codeRow: some View {
+ HStack(spacing: cellSpacing) {
+ ForEach(0 ..< codeLength, id: \.self) { index in
+ let digit = digit(at: index)
+ let centerOffset = CGFloat(codeLength - 1) / 2 - CGFloat(index)
+ DisplayText(digit ?? "•", textColor: digit != nil ? .textPrimary : .white32)
+ .frame(width: cellWidth)
+ .opacity(1 - progress)
+ .scaleEffect(x: 1 - 0.85 * progress, y: 1 - 0.15 * progress)
+ .offset(x: centerOffset * cellStep * progress)
+ }
+ }
+ .animation(.easeInOut(duration: 0.35), value: submitted)
+ }
+
+ private func digit(at index: Int) -> String? {
+ let characters = Array(code)
+ return index < characters.count ? String(characters[index]) : nil
+ }
+
+ private func handleKey(_ key: String) {
+ guard !submitted else { return }
+ if key == "delete" {
+ if !code.isEmpty { code.removeLast() }
+ } else if code.count < codeLength {
+ code += key
+ if code.count == codeLength {
+ submitted = true
+ onSubmit(code)
+ }
+ }
+ }
+}
+
+#Preview {
+ HwPairCodeView(onSubmit: { _ in })
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(Color.black)
+ .preferredColorScheme(.dark)
+}
diff --git a/Bitkit/Views/Sheets/HardwareConnect/HwPairedView.swift b/Bitkit/Views/Sheets/HardwareConnect/HwPairedView.swift
new file mode 100644
index 000000000..9141897e5
--- /dev/null
+++ b/Bitkit/Views/Sheets/HardwareConnect/HwPairedView.swift
@@ -0,0 +1,98 @@
+import SwiftUI
+
+/// Paired step: the device's watch-only balance plus an editable "Label Funds" field, over the coin
+/// illustration.
+struct HwPairedView: View {
+ let deviceName: String
+ let balanceSats: UInt64
+ @Binding var labelText: String
+ let onFinish: () -> Void
+
+ /// Coins illustration width as a fraction of the sheet — the 256-wide Visual in the 375-wide Figma frame.
+ private let coinsWidthRatio: CGFloat = 256.0 / 375.0
+
+ var body: some View {
+ ZStack(alignment: .bottom) {
+ GeometryReader { geo in
+ Image("coin-stack-3")
+ .resizable()
+ .scaledToFit()
+ .frame(width: geo.size.width * coinsWidthRatio)
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
+ }
+ .ignoresSafeArea(edges: .bottom)
+ .allowsHitTesting(false)
+
+ VStack(spacing: 0) {
+ SheetHeader(title: t("hardware__paired_title"))
+ .padding(.horizontal, 16)
+
+ VStack(alignment: .leading, spacing: 0) {
+ DisplayText(t("hardware__paired_header"), accentColor: .blueAccent)
+
+ BodyMText(t("hardware__paired_text"))
+ .padding(.top, 8)
+
+ HwPairedBalanceView(name: deviceName, sats: balanceSats)
+ .padding(.top, 32)
+
+ CaptionMText(t("hardware__paired_label"))
+ .padding(.top, 32)
+ .padding(.bottom, 8)
+
+ TextField(
+ deviceName,
+ text: $labelText,
+ testIdentifier: "HardwareWalletLabelInput"
+ )
+ }
+ .padding(.horizontal, 32)
+
+ Spacer(minLength: 0)
+
+ CustomButton(title: t("hardware__paired_finish"), shouldExpand: true) {
+ onFinish()
+ }
+ .accessibilityIdentifier("HardwareWalletPairedFinish")
+ .padding(.horizontal, 32)
+ .padding(.bottom, 16)
+ }
+ }
+ .accessibilityIdentifier("HardwareWalletPairedScreen")
+ }
+}
+
+/// Device name over its watch-only balance, styled like the app's wallet balance rows.
+private struct HwPairedBalanceView: View {
+ let name: String
+ let sats: UInt64
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ CaptionMText(name)
+
+ HStack(spacing: 8) {
+ Image("btc-circle-blue")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 24, height: 24)
+
+ MoneyText(sats: Int(clamping: sats), size: .subtitle, symbol: true, symbolColor: .textPrimary)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+}
+
+#Preview {
+ HwPairedView(
+ deviceName: "Trezor Safe 3",
+ balanceSats: 10_562_411,
+ labelText: .constant("Trezor Safe 3"),
+ onFinish: {}
+ )
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(Color.black)
+ .environmentObject(CurrencyViewModel())
+ .preferredColorScheme(.dark)
+}
diff --git a/Bitkit/Views/Sheets/HardwareConnect/HwSearchingAnimation.swift b/Bitkit/Views/Sheets/HardwareConnect/HwSearchingAnimation.swift
new file mode 100644
index 000000000..c0213188c
--- /dev/null
+++ b/Bitkit/Views/Sheets/HardwareConnect/HwSearchingAnimation.swift
@@ -0,0 +1,56 @@
+import SwiftUI
+
+/// The Figma "Loading Animation" hardware ring: a dashed outer ring and inner ring counter-rotating
+/// around a set of arrows.
+struct HwSearchingAnimation: View {
+ /// Diameter of the loading visual (outer dashed ring).
+ private let canvasSize: CGFloat = 280
+
+ /// Arrows width as a fraction of the loader — 256 in the 311-wide Figma HW ring.
+ private let arrowsRatio: CGFloat = 256.0 / 311.0
+
+ /// Inner dashed-ring width as a fraction of the loader — 207 in the 311-wide Figma HW ring.
+ private let innerRingRatio: CGFloat = 207.0 / 311.0
+
+ /// Seconds per full ring turn (both rings counter-rotate one turn every 2s).
+ private let ringSpin: Double = 2
+
+ /// Seconds per full arrows turn.
+ private let arrowsSpin: Double = 4
+
+ @State private var animate = false
+
+ var body: some View {
+ ZStack {
+ Image("hw-searching-ring")
+ .resizable()
+ .scaledToFit()
+ .frame(width: canvasSize, height: canvasSize)
+ .rotationEffect(.degrees(animate ? -360 : 0))
+ .animation(.linear(duration: ringSpin).repeatForever(autoreverses: false), value: animate)
+
+ Image("hw-searching-ring-inner")
+ .resizable()
+ .scaledToFit()
+ .frame(width: canvasSize * innerRingRatio, height: canvasSize * innerRingRatio)
+ .rotationEffect(.degrees(animate ? 360 : 0))
+ .animation(.linear(duration: ringSpin).repeatForever(autoreverses: false), value: animate)
+
+ Image("hw-searching-arrows")
+ .resizable()
+ .scaledToFit()
+ .frame(width: canvasSize * arrowsRatio, height: canvasSize * arrowsRatio)
+ .rotationEffect(.degrees(animate ? -360 : 0))
+ .animation(.linear(duration: arrowsSpin).repeatForever(autoreverses: false), value: animate)
+ }
+ .frame(width: canvasSize, height: canvasSize)
+ .onAppear { animate = true }
+ }
+}
+
+#Preview {
+ HwSearchingAnimation()
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(Color.black)
+ .preferredColorScheme(.dark)
+}
diff --git a/Bitkit/Views/Sheets/HardwareConnect/HwSearchingView.swift b/Bitkit/Views/Sheets/HardwareConnect/HwSearchingView.swift
new file mode 100644
index 000000000..512614d4e
--- /dev/null
+++ b/Bitkit/Views/Sheets/HardwareConnect/HwSearchingView.swift
@@ -0,0 +1,53 @@
+import SwiftUI
+
+/// Searching step of the Connect Hardware flow: the dashed-ring loading animation while scanning for
+/// a nearby device, with an inline error message when a scan pass fails.
+struct HwSearchingView: View {
+ let errorMessage: String?
+ let onCancel: () -> Void
+
+ var body: some View {
+ VStack(spacing: 0) {
+ SheetHeader(title: t("hardware__connect_title"))
+ .padding(.horizontal, 16)
+
+ VStack(alignment: .leading, spacing: 8) {
+ DisplayText(t("hardware__connect_header"), accentColor: .blueAccent)
+
+ BodyMText(
+ errorMessage ?? t("hardware__connect_text"),
+ textColor: errorMessage != nil ? .redAccent : .textSecondary
+ )
+ .frame(minHeight: 40, alignment: .top)
+ .accessibilityIdentifier(errorMessage != nil ? "HwSearchingError" : "HwSearchingText")
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 32)
+
+ HwSearchingAnimation()
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+
+ CustomButton(title: t("common__cancel"), variant: .secondary, shouldExpand: true) {
+ onCancel()
+ }
+ .accessibilityIdentifier("HwSearchingCancel")
+ .padding(.horizontal, 32)
+ .padding(.bottom, 16)
+ }
+ .accessibilityIdentifier("HardwareWalletSearchingScreen")
+ }
+}
+
+#Preview {
+ HwSearchingView(errorMessage: nil, onCancel: {})
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(Color.black)
+ .preferredColorScheme(.dark)
+}
+
+#Preview("Error") {
+ HwSearchingView(errorMessage: t("hardware__search_error"), onCancel: {})
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(Color.black)
+ .preferredColorScheme(.dark)
+}
diff --git a/Bitkit/Views/Sheets/HardwareConnectSheet.swift b/Bitkit/Views/Sheets/HardwareConnectSheet.swift
new file mode 100644
index 000000000..2b4ac44b2
--- /dev/null
+++ b/Bitkit/Views/Sheets/HardwareConnectSheet.swift
@@ -0,0 +1,210 @@
+import CoreBluetooth
+import SwiftUI
+
+struct HardwareConnectSheetItem: SheetItem {
+ let id: SheetID = .hardwareConnect
+ let size: SheetSize = .large
+}
+
+/// Entry point for the Connect Hardware flow.
+struct HardwareConnectSheet: View {
+ @Environment(TrezorManager.self) private var trezorManager
+ let config: HardwareConnectSheetItem
+
+ var body: some View {
+ HardwareConnectFlow(
+ service: TrezorHwConnectService(trezorManager: trezorManager),
+ config: config
+ )
+ }
+}
+
+private struct HardwareConnectFlow: View {
+ @Environment(TrezorManager.self) private var trezorManager
+ @Environment(HwWalletManager.self) private var hwWalletManager
+ @EnvironmentObject private var app: AppViewModel
+ @EnvironmentObject private var sheets: SheetViewModel
+ @EnvironmentObject private var navigation: NavigationViewModel
+
+ let config: HardwareConnectSheetItem
+
+ @State private var viewModel: HwConnectViewModel
+ @State private var showBluetoothAlert = false
+
+ init(service: HwConnectServicing, config: HardwareConnectSheetItem) {
+ self.config = config
+ _viewModel = State(initialValue: HwConnectViewModel(service: service))
+ }
+
+ var body: some View {
+ Sheet(id: .hardwareConnect, data: config) {
+ ZStack {
+ currentPhaseView
+ .id(viewModel.phase)
+ .transition(pushTransition)
+ }
+ .animation(.easeInOut(duration: 0.3), value: viewModel.phase)
+ }
+ .onChange(of: trezorManager.pairingCodeRequestID) { _, _ in
+ if trezorManager.showPairingCode { viewModel.onPairingCodeRequested() }
+ }
+ .onChange(of: viewModel.isConnecting) { _, connecting in
+ sheets.hardwareConnectHandlesPairing = connecting
+ }
+ .onChange(of: connectedWalletKey) { _, _ in
+ viewModel.onWalletsUpdated(hwWalletManager.wallets)
+ }
+ .task {
+ viewModel.onFinished = {
+ sheets.hideSheet()
+ navigation.reset()
+ app.requestedHomePage = 0
+ }
+ }
+ .onDisappear {
+ viewModel.reset()
+ sheets.hardwareConnectHandlesPairing = false
+ }
+ .alert(bluetoothAlertTitle, isPresented: $showBluetoothAlert) {
+ Button(t("common__cancel"), role: .cancel) {}
+ Button(t("hardware__bluetooth_open_settings")) { openSettings() }
+ } message: {
+ Text(bluetoothAlertMessage)
+ }
+ }
+
+ @ViewBuilder
+ private var currentPhaseView: some View {
+ switch viewModel.phase {
+ case .intro:
+ introStep
+ case .searching:
+ HwSearchingView(errorMessage: viewModel.errorMessage, onCancel: { sheets.hideSheet() })
+ case .found:
+ HwFoundView(
+ deviceModel: foundDeviceModel,
+ isConnecting: viewModel.isConnecting,
+ errorMessage: viewModel.errorMessage,
+ onConnect: viewModel.onConnect,
+ onCancel: {
+ viewModel.cancelConnect()
+ sheets.hideSheet()
+ }
+ )
+ case .paired:
+ HwPairedView(
+ deviceName: viewModel.deviceName,
+ balanceSats: viewModel.balanceSats,
+ labelText: labelBinding,
+ onFinish: viewModel.onFinish
+ )
+ case .pairCode:
+ HwPairCodeView(onSubmit: { trezorManager.submitPairingCode($0) })
+ .id(trezorManager.pairingCodeRequestID)
+ }
+ }
+
+ /// Steps slide in from the trailing edge as the flow advances, mirroring the push transitions of
+ /// the app's other multi-step sheets, without a `NavigationStack` (which would draw its own
+ /// background over the sheet gradient). Cancel and back still dismiss the sheet.
+ private var pushTransition: AnyTransition {
+ .asymmetric(
+ insertion: .move(edge: .trailing),
+ removal: .move(edge: .leading)
+ )
+ }
+
+ // MARK: - Intro step
+
+ private var introStep: some View {
+ VStack(spacing: 0) {
+ SheetHeader(title: t("hardware__intro_title"))
+ .padding(.horizontal, 16)
+
+ HwDeviceIllustrations()
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+
+ VStack(alignment: .leading, spacing: 0) {
+ DisplayText(t("hardware__intro_header"), accentColor: .blueAccent)
+
+ BodyMText(t("hardware__intro_text"))
+ .padding(.top, 8)
+
+ HStack(spacing: 16) {
+ CustomButton(title: t("common__cancel"), variant: .secondary, shouldExpand: true) {
+ sheets.hideSheet()
+ }
+ .accessibilityIdentifier("HwIntroCancel")
+
+ CustomButton(title: t("common__continue"), shouldExpand: true) {
+ onContinueTapped()
+ }
+ .accessibilityIdentifier("HwIntroContinue")
+ }
+ .padding(.top, 32)
+ .padding(.bottom, 16)
+ }
+ .padding(.horizontal, 32)
+ }
+ .accessibilityIdentifier("HwIntroSheet")
+ }
+
+ private func onContinueTapped() {
+ if isBluetoothUsable {
+ viewModel.onIntroContinue()
+ } else {
+ showBluetoothAlert = true
+ }
+ }
+
+ // MARK: - Helpers
+
+ private var foundDeviceModel: String {
+ viewModel.foundDeviceModel.isEmpty ? t("hardware__device_model_trezor") : viewModel.foundDeviceModel
+ }
+
+ private var labelBinding: Binding {
+ Binding(get: { viewModel.labelInput }, set: { viewModel.onLabelChange($0) })
+ }
+
+ /// Changes whenever the paired wallet's name or balance changes, so the balance shown on the
+ /// Paired step tracks incoming watcher updates.
+ private var connectedWalletKey: String {
+ guard let deviceId = viewModel.pairedDeviceId else { return "" }
+ guard let wallet = hwWalletManager.wallets.first(where: { $0.id == deviceId || $0.deviceIds.contains(deviceId) })
+ else { return "" }
+ return "\(wallet.name)\u{1}\(wallet.balanceSats)"
+ }
+
+ private var isBluetoothUsable: Bool {
+ switch trezorManager.bluetoothState {
+ case .poweredOn, .unknown, .resetting:
+ return true
+ default:
+ return false
+ }
+ }
+
+ private var bluetoothAlertTitle: String {
+ switch trezorManager.bluetoothState {
+ case .poweredOff: return t("hardware__bluetooth_off_title")
+ case .unauthorized: return t("hardware__bluetooth_unauthorized_title")
+ case .unsupported: return t("hardware__bluetooth_unsupported_title")
+ default: return t("hardware__bluetooth_unavailable_title")
+ }
+ }
+
+ private var bluetoothAlertMessage: String {
+ switch trezorManager.bluetoothState {
+ case .poweredOff: return t("hardware__bluetooth_off_text")
+ case .unauthorized: return t("hardware__bluetooth_unauthorized_text")
+ case .unsupported: return t("hardware__bluetooth_unsupported_text")
+ default: return t("hardware__bluetooth_unavailable_text")
+ }
+ }
+
+ private func openSettings() {
+ guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
+ UIApplication.shared.open(url)
+ }
+}
diff --git a/Bitkit/Views/Sheets/HardwareIntroSheet.swift b/Bitkit/Views/Sheets/HardwareIntroSheet.swift
deleted file mode 100644
index 12bf3efd3..000000000
--- a/Bitkit/Views/Sheets/HardwareIntroSheet.swift
+++ /dev/null
@@ -1,47 +0,0 @@
-import SwiftUI
-
-struct HardwareIntroSheetItem: SheetItem {
- let id: SheetID = .hardwareIntro
- let size: SheetSize = .large
-}
-
-/// Intro sheet opened from the Hardware suggestion card. Mirrors bitkit-android's `HwIntroSheet`:
-/// staggered device hero, accent title, copy, and Cancel + (disabled) Continue. Continue is
-/// intentionally disabled — the connect flow ships in a later PR.
-struct HardwareIntroSheet: View {
- @EnvironmentObject private var sheets: SheetViewModel
- let config: HardwareIntroSheetItem
-
- var body: some View {
- Sheet(id: .hardwareIntro, data: config) {
- VStack(spacing: 0) {
- SheetHeader(title: t("hardware__intro_title"))
- .padding(.horizontal, 16)
-
- HwDeviceIllustrations()
- .frame(maxWidth: .infinity, maxHeight: .infinity)
-
- VStack(alignment: .leading, spacing: 0) {
- DisplayText(t("hardware__intro_header"), accentColor: .blueAccent)
-
- BodyMText(t("hardware__intro_text"))
- .padding(.top, 8)
-
- HStack(spacing: 16) {
- CustomButton(title: t("common__cancel"), variant: .secondary, shouldExpand: true) {
- sheets.hideSheet()
- }
- .accessibilityIdentifier("HwIntroCancel")
-
- CustomButton(title: t("common__continue"), isDisabled: true, shouldExpand: true)
- .accessibilityIdentifier("HwIntroContinue")
- }
- .padding(.top, 32)
- .padding(.bottom, 16)
- }
- .padding(.horizontal, 32)
- }
- .accessibilityIdentifier("HwIntroSheet")
- }
- }
-}
diff --git a/BitkitTests/HwConnectViewModelTests.swift b/BitkitTests/HwConnectViewModelTests.swift
new file mode 100644
index 000000000..0d7f31c07
--- /dev/null
+++ b/BitkitTests/HwConnectViewModelTests.swift
@@ -0,0 +1,214 @@
+@testable import Bitkit
+import BitkitCore
+import XCTest
+
+@MainActor
+final class HwConnectViewModelTests: XCTestCase {
+ private var service: FakeHwConnectService!
+ private var sut: HwConnectViewModel!
+
+ override func setUp() {
+ super.setUp()
+ service = FakeHwConnectService()
+ sut = HwConnectViewModel(service: service)
+ }
+
+ override func tearDown() {
+ sut.reset()
+ sut = nil
+ service = nil
+ super.tearDown()
+ }
+
+ // MARK: - Searching
+
+ func testOnIntroContinueSearchesThenAdvancesToFoundWithFirstDevice() async {
+ service.nearbyDevices = [makeDevice(id: "dev1", model: "Safe 3")]
+
+ sut.onIntroContinue()
+
+ await waitUntil { self.sut.phase == .found }
+ XCTAssertEqual(sut.phase, .found)
+ XCTAssertEqual(sut.foundDevice?.id, "dev1")
+ XCTAssertEqual(sut.foundDeviceModel, "Trezor Safe 3")
+ XCTAssertNil(sut.errorMessage)
+ }
+
+ func testOnIntroContinueSurfacesSearchFailureWhileSearching() async {
+ service.scanError = TestError.stub
+
+ sut.onIntroContinue()
+
+ await waitUntil { self.sut.errorMessage != nil }
+ XCTAssertEqual(sut.phase, .searching)
+ XCTAssertEqual(sut.errorMessage, t("hardware__search_error"))
+ }
+
+ // MARK: - Connect
+
+ func testOnConnectConnectsFoundDeviceAndAdvancesToPaired() async {
+ await givenDeviceFound()
+ service.connectResult = .success(HwConnectResult(deviceId: "dev1", name: "Trezor Safe 3"))
+
+ sut.onConnect()
+
+ await waitUntil { self.sut.phase == .paired }
+ XCTAssertEqual(service.connectedDeviceIds, ["dev1"])
+ XCTAssertEqual(sut.pairedDeviceId, "dev1")
+ XCTAssertEqual(sut.deviceName, "Trezor Safe 3")
+ XCTAssertEqual(sut.labelInput, "Trezor Safe 3")
+ XCTAssertFalse(sut.isConnecting)
+ }
+
+ func testOnConnectSurfacesRealErrorMessageAndReturnsToFound() async {
+ await givenDeviceFound()
+ let realMessage = t("hardware__pairing_code_invalid")
+ // Use the module-qualified type: `Errors.swift` is also compiled into the test target, so an
+ // unqualified `AppError` here is a distinct `BitkitTests.AppError` that the view model's
+ // `Bitkit.AppError` cast would reject. Production throws `Bitkit.AppError`, so mirror that.
+ service.connectResult = .failure(Bitkit.AppError(message: realMessage, debugMessage: nil))
+
+ sut.onConnect()
+
+ await waitUntil { self.sut.errorMessage != nil }
+ XCTAssertEqual(sut.phase, .found)
+ XCTAssertFalse(sut.isConnecting)
+ XCTAssertEqual(sut.errorMessage, realMessage)
+ XCTAssertEqual(sut.foundDevice?.id, "dev1")
+ }
+
+ func testOnConnectFallsBackToGenericErrorForNonAppError() async {
+ await givenDeviceFound()
+ service.connectResult = .failure(TestError.stub)
+
+ sut.onConnect()
+
+ await waitUntil { self.sut.errorMessage != nil }
+ XCTAssertEqual(sut.phase, .found)
+ XCTAssertFalse(sut.isConnecting)
+ XCTAssertEqual(sut.errorMessage, t("hardware__connect_error"))
+ }
+
+ // MARK: - Pairing code
+
+ func testPairingCodeRequestSurfacesInlinePairCodeStepWhileConnecting() async {
+ await givenDeviceFound()
+ service.connectResult = .success(HwConnectResult(deviceId: "dev1", name: "Trezor Safe 3"))
+
+ // onConnect flips isConnecting synchronously; the connect Task is queued but not yet run,
+ // so the pairing-code request lands mid-connect exactly as it would on device.
+ sut.onConnect()
+ XCTAssertTrue(sut.isConnecting)
+ sut.onPairingCodeRequested()
+
+ XCTAssertEqual(sut.phase, .pairCode)
+ }
+
+ func testPairingCodeRequestIgnoredWhenNotConnecting() {
+ sut.onPairingCodeRequested()
+ XCTAssertEqual(sut.phase, .intro)
+ }
+
+ // MARK: - Paired
+
+ func testConnectedWalletUpdatesBalanceOnPairedStep() async {
+ await givenDevicePaired()
+
+ sut.onWalletsUpdated([makeWallet(id: "dev1", name: "Trezor Safe 3", balance: 10_562_411)])
+
+ XCTAssertEqual(sut.balanceSats, 10_562_411)
+ XCTAssertEqual(sut.deviceName, "Trezor Safe 3")
+ }
+
+ func testOnLabelChangeCapsTheLabelInput() {
+ sut.onLabelChange(String(repeating: "a", count: 51))
+ XCTAssertEqual(sut.labelInput, String(repeating: "a", count: 50))
+ }
+
+ func testOnFinishPersistsEditedLabelAndFinishes() async {
+ await givenDevicePaired()
+ sut.onLabelChange("My Cold Wallet")
+ var finished = false
+ sut.onFinished = { finished = true }
+
+ sut.onFinish()
+
+ XCTAssertEqual(service.setLabelCalls.count, 1)
+ XCTAssertEqual(service.setLabelCalls.first?.id, "dev1")
+ XCTAssertEqual(service.setLabelCalls.first?.label, "My Cold Wallet")
+ XCTAssertTrue(finished)
+ }
+
+ // MARK: - Helpers
+
+ private func givenDeviceFound() async {
+ service.nearbyDevices = [makeDevice(id: "dev1", model: "Safe 3")]
+ sut.onIntroContinue()
+ await waitUntil { self.sut.phase == .found }
+ }
+
+ private func givenDevicePaired() async {
+ await givenDeviceFound()
+ service.connectResult = .success(HwConnectResult(deviceId: "dev1", name: "Trezor Safe 3"))
+ sut.onConnect()
+ await waitUntil { self.sut.phase == .paired }
+ }
+
+ private func makeDevice(id: String, model: String?) -> TrezorDeviceInfo {
+ TrezorDeviceInfo(
+ id: id,
+ transportType: .bluetooth,
+ name: nil,
+ path: "ble:\(id)",
+ label: nil,
+ model: model,
+ isBootloader: false
+ )
+ }
+
+ private func makeWallet(id: String, name: String, balance: UInt64) -> HwWallet {
+ HwWallet(id: id, walletId: "trezor:\(id)", name: name, model: nil, isConnected: true, balanceSats: balance)
+ }
+
+ private func waitUntil(timeout: TimeInterval = 2, _ condition: () -> Bool) async {
+ let deadline = Date().addingTimeInterval(timeout)
+ while !condition(), Date() < deadline {
+ try? await Task.sleep(nanoseconds: 10_000_000)
+ }
+ }
+}
+
+private enum TestError: Error {
+ case stub
+}
+
+@MainActor
+private final class FakeHwConnectService: HwConnectServicing {
+ var nearbyDevices: [TrezorDeviceInfo] = []
+ var scanError: Error?
+ var connectResult: Result = .failure(TestError.stub)
+
+ private(set) var scanCount = 0
+ private(set) var connectedDeviceIds: [String] = []
+ private(set) var setLabelCalls: [(id: String, label: String)] = []
+ private(set) var cancelPairingCount = 0
+
+ func scanForUnpairedDevices() async throws -> [TrezorDeviceInfo] {
+ scanCount += 1
+ if let scanError { throw scanError }
+ return nearbyDevices
+ }
+
+ func connect(to device: TrezorDeviceInfo) async throws -> HwConnectResult {
+ connectedDeviceIds.append(device.id)
+ return try connectResult.get()
+ }
+
+ func setDeviceLabel(id: String, label: String) {
+ setLabelCalls.append((id, label))
+ }
+
+ func cancelPairingCode() {
+ cancelPairingCount += 1
+ }
+}
diff --git a/changelog.d/next/614.added.md b/changelog.d/next/614.added.md
new file mode 100644
index 000000000..659af0007
--- /dev/null
+++ b/changelog.d/next/614.added.md
@@ -0,0 +1 @@
+Connect a Trezor hardware wallet from the home suggestion card or Hardware Wallets settings to watch its balance, with a guided pairing flow that finds the device, enters the one-time pairing code, and labels the funds.