diff --git a/Bitkit/Assets.xcassets/Illustrations/trezor-device.imageset/Contents.json b/Bitkit/Assets.xcassets/Illustrations/trezor-device.imageset/Contents.json new file mode 100644 index 000000000..9f80c7310 --- /dev/null +++ b/Bitkit/Assets.xcassets/Illustrations/trezor-device.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "filename" : "trezor-device.png", "idiom" : "universal", "scale" : "1x" }, + { "filename" : "trezor-device@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "trezor-device@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/Bitkit/Assets.xcassets/Illustrations/trezor-device.imageset/trezor-device.png b/Bitkit/Assets.xcassets/Illustrations/trezor-device.imageset/trezor-device.png new file mode 100644 index 000000000..a022b5a14 Binary files /dev/null and b/Bitkit/Assets.xcassets/Illustrations/trezor-device.imageset/trezor-device.png differ diff --git a/Bitkit/Assets.xcassets/Illustrations/trezor-device.imageset/trezor-device@2x.png b/Bitkit/Assets.xcassets/Illustrations/trezor-device.imageset/trezor-device@2x.png new file mode 100644 index 000000000..9146f6da5 Binary files /dev/null and b/Bitkit/Assets.xcassets/Illustrations/trezor-device.imageset/trezor-device@2x.png differ diff --git a/Bitkit/Assets.xcassets/Illustrations/trezor-device.imageset/trezor-device@3x.png b/Bitkit/Assets.xcassets/Illustrations/trezor-device.imageset/trezor-device@3x.png new file mode 100644 index 000000000..54310462f Binary files /dev/null and b/Bitkit/Assets.xcassets/Illustrations/trezor-device.imageset/trezor-device@3x.png differ diff --git a/Bitkit/Components/TabBar/TabBar.swift b/Bitkit/Components/TabBar/TabBar.swift index 899e93e5c..e112ba082 100644 --- a/Bitkit/Components/TabBar/TabBar.swift +++ b/Bitkit/Components/TabBar/TabBar.swift @@ -8,10 +8,15 @@ struct TabBar: View { var shouldShow: Bool { if calculatorInput.isPresented { return false } - - let routesWithTabBar = Set([.activityList, .savingsWallet, .spendingWallet]) if navigation.path.isEmpty { return true } - return navigation.currentRoute.map { routesWithTabBar.contains($0) } ?? false + guard let route = navigation.currentRoute else { return false } + + switch route { + case .activityList, .savingsWallet, .spendingWallet, .hardwareWallet: + return true + default: + return false + } } var body: some View { diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 311e8badd..62d5c6ad4 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -413,6 +413,7 @@ struct MainNavView: View { case .buyBitcoin: BuyBitcoinView() case .savingsWallet: SavingsWalletScreen() case .spendingWallet: SpendingWalletScreen() + case let .hardwareWallet(deviceId): HardwareWalletScreen(deviceId: deviceId) case .scanner: ScannerScreen() // Transfer diff --git a/Bitkit/Managers/TrezorManager.swift b/Bitkit/Managers/TrezorManager.swift index 50f9451a5..729dbc6c2 100644 --- a/Bitkit/Managers/TrezorManager.swift +++ b/Bitkit/Managers/TrezorManager.swift @@ -528,7 +528,10 @@ final class TrezorManager { } func forgetDevice(id: String) async { - if let device = knownDevices.first(where: { $0.id == id }) { + let known = knownDevices.first(where: { $0.id == id }) + let isActiveSession = connectedDevice?.id == id || (known.map { connectedDevice?.path == $0.path } ?? false) + + if let device = known { do { try await trezorService.clearCredentials(deviceId: device.path) } catch { @@ -539,6 +542,10 @@ final class TrezorManager { TrezorKnownDeviceStorage.remove(id: id) loadKnownDevices() trezorLog("Forgot device: \(id)") + + if isActiveSession { + await disconnect() + } } // MARK: - Auto-Reconnect diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 234181839..b56a60446 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -38,6 +38,10 @@ "hardware__intro_text" = "Connect your hardware device to watch or manage your long-term funds."; "hardware__pairing_title" = "Pair Device"; "hardware__pairing_text" = "Enter the 6-digit code shown on your hardware device."; +"hardware__remove_button" = "Remove {name}"; +"hardware__remove_dialog_title" = "Remove {name}"; +"hardware__remove_dialog_text" = "Don't worry, your funds are safe and your coins won't be deleted. Bitkit will simply stop displaying the amounts in the wallet."; +"hardware__transfer_not_implemented" = "Transfer to spending not yet implemented."; "cards__buyBitcoin__title" = "Buy"; "cards__buyBitcoin__description" = "Buy some bitcoin"; "cards__btFailed__title" = "Failed"; @@ -55,6 +59,7 @@ "common__yes_proceed" = "Yes, Proceed"; "common__try_again" = "Try Again"; "common__dialog_cancel" = "Cancel"; +"common__remove" = "Remove"; "common__sat_vbyte" = "₿ / vbyte"; "common__sat_vbyte_compact" = "₿/vbyte"; "common__copy" = "Copy"; diff --git a/Bitkit/ViewModels/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift index 8f43c8f59..69503430f 100644 --- a/Bitkit/ViewModels/NavigationViewModel.swift +++ b/Bitkit/ViewModels/NavigationViewModel.swift @@ -5,6 +5,7 @@ import SwiftUI enum Route: Hashable { case savingsWallet case spendingWallet + case hardwareWallet(deviceId: String) case activityList case activityDetail(Activity) case activityExplorer(Activity) diff --git a/Bitkit/Views/Home/HomeWalletView.swift b/Bitkit/Views/Home/HomeWalletView.swift index 0a033425e..f237f6206 100644 --- a/Bitkit/Views/Home/HomeWalletView.swift +++ b/Bitkit/Views/Home/HomeWalletView.swift @@ -3,6 +3,7 @@ import SwiftUI struct HomeWalletView: View { @EnvironmentObject var activity: ActivityListViewModel @EnvironmentObject var app: AppViewModel + @EnvironmentObject var navigation: NavigationViewModel @EnvironmentObject var settings: SettingsViewModel @EnvironmentObject var wallet: WalletViewModel @Environment(HwWalletManager.self) private var hwWalletManager @@ -52,8 +53,8 @@ struct HomeWalletView: View { .padding(.bottom, 32) if !hwWalletManager.wallets.isEmpty { - HardwareWalletsGrid(wallets: hwWalletManager.wallets) { _ in - app.toast(type: .info, title: t("coming_soon__nav_title")) + HardwareWalletsGrid(wallets: hwWalletManager.wallets) { hwWallet in + navigation.navigate(.hardwareWallet(deviceId: hwWallet.id)) } .padding(.bottom, 32) } diff --git a/Bitkit/Views/Wallets/HardwareWalletScreen.swift b/Bitkit/Views/Wallets/HardwareWalletScreen.swift new file mode 100644 index 000000000..283ca6931 --- /dev/null +++ b/Bitkit/Views/Wallets/HardwareWalletScreen.swift @@ -0,0 +1,207 @@ +import BitkitCore +import SwiftUI + +/// Detail overview of a paired hardware wallet, tracked as a watch-only balance. Mirrors the +/// Savings/Spending screens: device name + blue Bitcoin icon in the top bar, balance header, the +/// device's on-chain activity grouped by date (blue hardware icons), a Transfer-To-Spending +/// placeholder on funded devices, and a Remove action. Ports bitkit-android's `HardwareWalletScreen`. +struct HardwareWalletScreen: View { + let deviceId: String + + @EnvironmentObject var activity: ActivityListViewModel + @EnvironmentObject var app: AppViewModel + @EnvironmentObject var navigation: NavigationViewModel + @Environment(HwWalletManager.self) private var hwWalletManager + @Environment(TrezorManager.self) private var trezorManager + + @State private var activities: [Activity] = [] + @State private var showRemoveDialog = false + + private var wallet: HwWallet? { + hwWalletManager.wallets.first { $0.deviceIds.contains(deviceId) } + } + + var body: some View { + let wallet = wallet + + return ZStack(alignment: .top) { + if let wallet { + NavigationBar(title: wallet.name, icon: "btc-circle-blue") + .padding(.horizontal, 16) + + content(for: wallet) + + bottomGradient + } + } + .navigationBarHidden(true) + .task(id: wallet?.walletId) { + await loadActivities() + } + .onReceive(activity.activitiesChangedPublisher) { _ in + Task { await loadActivities() } + } + // Leave the screen once the device is gone, whether removed here or forgotten elsewhere. + .onChange(of: wallet != nil) { _, stillPaired in + if hwWalletManager.walletsLoaded, !stillPaired { + navigation.navigateBack() + } + } + .alert( + t("hardware__remove_dialog_title", variables: ["name": wallet?.name ?? ""]), + isPresented: $showRemoveDialog + ) { + Button(t("common__remove"), role: .destructive) { + Task { await removeWallet() } + } + Button(t("common__dialog_cancel"), role: .cancel) {} + } message: { + Text(t("hardware__remove_dialog_text")) + } + } + + private func content(for wallet: HwWallet) -> some View { + let hasFunds = wallet.balanceSats > 0 + let hasActivity = !activities.isEmpty + + return VStack(spacing: 0) { + ScrollView(showsIndicators: false) { + MoneyStack( + sats: Int(clamping: wallet.balanceSats), + showSymbol: true, + enableSwipeGesture: true, + enableHide: true, + testIdPrefix: "TotalBalance" + ) + + if hasFunds { + transferButton + .padding(.top, 28) + } + + if hasActivity { + HardwareWalletActivityList(activities: activities) + .padding(.top, 32) + } + + removeButton(for: wallet) + .padding(.top, 16) + } + .contentMargins(.top, ScreenLayout.topPaddingWithoutSafeArea) + .contentMargins(.bottom, ScreenLayout.bottomPaddingWithSafeArea) + .frame(maxWidth: .infinity, minHeight: 400) + } + .padding(.horizontal) + .background(alignment: .topTrailing) { + trezorIllustration + // Align the device's top with the balance header and bleed off the trailing edge. + .offset(x: 118, y: ScreenLayout.topPaddingWithoutSafeArea) + } + } + + /// The shared upright Trezor device, transformed to match the Figma "Wallet Overview" visual: + /// cover-filled into a square, rotated -15°, and clipped to a 256pt box that bleeds off the + /// screen's trailing edge. Reuses the generic `trezor-device` asset — no screen-specific crop is + /// baked in, so the same image can be adapted elsewhere with SwiftUI. + private var trezorIllustration: some View { + Image("trezor-device") + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 268, height: 268) + .clipped() + .rotationEffect(.degrees(-15)) + .offset(x: 9, y: 13) + .frame(width: 256, height: 256) + .clipped() + } + + private var transferButton: some View { + CustomButton( + title: t("lightning__transfer_to_spending_button"), + variant: .secondary, + icon: Image("arrow-up-down") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + .foregroundColor(.white80) + ) { + app.toast(type: .warning, title: t("hardware__transfer_not_implemented")) + } + .accessibilityIdentifier("HwTransferToSpending") + } + + private func removeButton(for wallet: HwWallet) -> some View { + CustomButton( + title: t("hardware__remove_button", variables: ["name": wallet.name]), + variant: .tertiary + ) { + showRemoveDialog = true + } + .accessibilityIdentifier("RemoveHardwareWallet") + } + + private var bottomGradient: some View { + VStack { + Spacer() + LinearGradient( + colors: [.black.opacity(0), .black], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: ScreenLayout.bottomPaddingWithSafeArea) + } + .ignoresSafeArea(edges: .bottom) + .allowsHitTesting(false) + } + + @MainActor + private func loadActivities() async { + guard let walletId = wallet?.walletId else { return } + do { + activities = try await CoreService.shared.activity.get(filter: .all, walletId: walletId) + } catch { + Logger.error(error, context: "HardwareWalletScreen failed to load activities") + } + } + + /// Stop watching and forget every entry for this wallet (the same device may be paired over + /// multiple transports). `removeDevice` stops the watchers and deletes the persisted activities; + /// `forgetDevice` clears credentials and drops the known-device entry, which re-pushes the device + /// snapshot and removes the tile. The reactive auto-pop above then leaves the screen. + private func removeWallet() async { + guard let wallet else { return } + hwWalletManager.removeDevice(id: wallet.id) + for id in wallet.deviceIds { + await trezorManager.forgetDevice(id: id) + } + } +} + +/// The hardware wallet's on-chain activity, grouped by date and rendered with the shared activity +/// row. Hardware activities draw the blue icon automatically (derived from their `walletId`). +private struct HardwareWalletActivityList: View { + @EnvironmentObject var activity: ActivityListViewModel + @EnvironmentObject var feeEstimatesManager: FeeEstimatesManager + + let activities: [Activity] + + var body: some View { + let groupedItems = activity.groupActivities(activities) + + LazyVStack(alignment: .leading, spacing: 16) { + ForEach(Array(zip(groupedItems.indices, groupedItems)), id: \.1) { index, groupItem in + switch groupItem { + case let .header(title): + CaptionMText(title) + .frame(height: 34, alignment: .bottom) + + case let .activity(item): + NavigationLink(value: Route.activityDetail(item)) { + ActivityRow(item: item, feeEstimates: feeEstimatesManager.estimates) + } + .accessibilityIdentifier("Activity-\(index)") + } + } + } + } +}