Replaced the transaction screen's balance card with a hero header featuring a wallet picker dropdown overlay that supports switching wallets, adding new wallets, and editing/deleting wallets — moving all wallet management out of Settings. Added wallet identicons (unique color grids per wallet) to the picker and header, and added a "Wallet Info" option to the transaction screen's menu. Improved the Electrum connection status to show "Connected" as soon as the chain tip is fetched rather than waiting for a full sync to complete. Changed the default address gap limit from 50 to20, and made various UX improvements including larger tap targets, consistent Done button styling, and auto-focus on wallet rename.
This commit is contained in:
parent
b7b0e7da3c
commit
7642fe8190
@ -419,7 +419,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 21;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"hellbender/Preview Content\"";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@ -452,7 +452,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 21;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"hellbender/Preview Content\"";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
|
||||
@ -31,7 +31,7 @@ final class WalletProfile {
|
||||
internalDescriptor: String,
|
||||
network: BitcoinNetwork = .testnet4,
|
||||
isActive: Bool = false,
|
||||
addressGapLimit: Int = 50,
|
||||
addressGapLimit: Int = 20,
|
||||
electrumHost: String = "",
|
||||
electrumPort: Int = 0,
|
||||
electrumSSL: Int = 0,
|
||||
|
||||
@ -316,6 +316,18 @@ final class BitcoinService {
|
||||
try await Task.detached { try client.ping() }.value
|
||||
addToLog("Server ping OK")
|
||||
|
||||
// Fetch chain tip early to confirm server returns real data
|
||||
if let header = await Task.detached(operation: { [client] in try? client.blockHeadersSubscribe() }).value {
|
||||
guard currentProfile?.id == syncProfileId else {
|
||||
addToLog("Sync cancelled: wallet switched during chain tip fetch")
|
||||
return
|
||||
}
|
||||
chainTipHeight = UInt32(header.height)
|
||||
electrumVerified = true
|
||||
electrumConnectionError = nil
|
||||
addToLog("Chain tip height: \(chainTipHeight)")
|
||||
}
|
||||
|
||||
if needsFullScan {
|
||||
let gapLimit = currentProfile?.addressGapLimit ?? Constants.maxAddressGap
|
||||
addToLog("Starting full scan (gapLimit: \(gapLimit))")
|
||||
@ -405,17 +417,6 @@ final class BitcoinService {
|
||||
_ = try wallet.persist(persister: syncPersister)
|
||||
}
|
||||
|
||||
// Update chain tip height for confirmation count calculation
|
||||
addToLog("Fetching chain tip height")
|
||||
if let header = await Task.detached(operation: { [client] in try? client.blockHeadersSubscribe() }).value {
|
||||
guard currentProfile?.id == syncProfileId else {
|
||||
addToLog("Sync cancelled: wallet switched during chain tip fetch")
|
||||
return
|
||||
}
|
||||
chainTipHeight = UInt32(header.height)
|
||||
addToLog("Chain tip height: \(chainTipHeight)")
|
||||
}
|
||||
|
||||
// Verify wallet identity one more time after final await
|
||||
guard currentProfile?.id == syncProfileId else {
|
||||
addToLog("Sync completed but wallet switched — discarding results")
|
||||
@ -427,8 +428,6 @@ final class BitcoinService {
|
||||
|
||||
let now = Date()
|
||||
lastSyncDate = now
|
||||
electrumVerified = true
|
||||
electrumConnectionError = nil
|
||||
setSyncState(.synced(now), for: syncProfileId)
|
||||
addToLog("Sync completed successfully")
|
||||
} catch {
|
||||
|
||||
@ -51,7 +51,7 @@ enum Constants {
|
||||
|
||||
static let maxCosigners = 10
|
||||
static let minCosigners = 1
|
||||
static let maxAddressGap = 50
|
||||
static let maxAddressGap = 20
|
||||
|
||||
static func derivationPath(for network: BitcoinNetwork) -> String {
|
||||
"m/48'/\(network.coinType)'/0'/2'"
|
||||
|
||||
@ -78,7 +78,7 @@ final class SetupWizardViewModel {
|
||||
|
||||
// Advanced settings
|
||||
var blockExplorerHost: String = ""
|
||||
var addressGapLimit: String = "50"
|
||||
var addressGapLimit: String = "20"
|
||||
|
||||
// State
|
||||
var errorMessage: String?
|
||||
|
||||
@ -20,6 +20,12 @@ final class TransactionListViewModel {
|
||||
bitcoinService.syncState
|
||||
}
|
||||
|
||||
func clearState() {
|
||||
transactions = []
|
||||
balance = 0
|
||||
isLoading = true
|
||||
}
|
||||
|
||||
func loadActiveWallet(from wallets: [WalletProfile]) {
|
||||
guard let active = wallets.first(where: { $0.isActive }) else { return }
|
||||
expectedProfileId = active.id
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import CryptoKit
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Theme Data
|
||||
@ -10,6 +11,7 @@ struct HBTheme {
|
||||
var textPrimary: Color
|
||||
var textSecondary: Color
|
||||
var accent: Color
|
||||
var heroBackground: Color
|
||||
var success: Color
|
||||
var error: Color
|
||||
var colorScheme: ColorScheme? = .dark
|
||||
@ -22,6 +24,7 @@ struct HBTheme {
|
||||
textPrimary: Color(.label),
|
||||
textSecondary: Color(.secondaryLabel),
|
||||
accent: Color(red: 0.969, green: 0.576, blue: 0.102),
|
||||
heroBackground: Color(.tertiarySystemBackground),
|
||||
success: Color(.systemGreen),
|
||||
error: Color(.systemRed),
|
||||
colorScheme: nil
|
||||
@ -35,6 +38,7 @@ struct HBTheme {
|
||||
textPrimary: Color(red: 0.910, green: 0.902, blue: 0.890),
|
||||
textSecondary: Color(red: 0.420, green: 0.420, blue: 0.463),
|
||||
accent: Color(red: 0.969, green: 0.576, blue: 0.102),
|
||||
heroBackground: Color(red: 0.110, green: 0.110, blue: 0.141),
|
||||
success: Color(red: 0.176, green: 0.545, blue: 0.341),
|
||||
error: Color(red: 0.851, green: 0.267, blue: 0.267),
|
||||
colorScheme: .dark
|
||||
@ -48,6 +52,7 @@ struct HBTheme {
|
||||
textPrimary: Color(red: 0.110, green: 0.110, blue: 0.118),
|
||||
textSecondary: Color(red: 0.557, green: 0.557, blue: 0.576),
|
||||
accent: Color(red: 0.969, green: 0.576, blue: 0.102),
|
||||
heroBackground: Color(red: 0.930, green: 0.930, blue: 0.945),
|
||||
success: Color(red: 0.204, green: 0.780, blue: 0.349),
|
||||
error: Color(red: 1.000, green: 0.231, blue: 0.188),
|
||||
colorScheme: .light
|
||||
@ -122,6 +127,11 @@ extension Color {
|
||||
ThemeManager.shared.theme.border
|
||||
}
|
||||
|
||||
/// Hero
|
||||
static var hbHeroBackground: Color {
|
||||
ThemeManager.shared.theme.heroBackground
|
||||
}
|
||||
|
||||
/// Accents
|
||||
static var hbBitcoinOrange: Color {
|
||||
ThemeManager.shared.theme.accent
|
||||
@ -261,6 +271,54 @@ struct NetworkBadge: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Wallet Identicon
|
||||
|
||||
struct WalletIdenticon: View {
|
||||
let id: UUID
|
||||
|
||||
private let gridSize = 4
|
||||
private let palette: [Color] = [
|
||||
Color.hbBitcoinOrange,
|
||||
Color.hbSteelBlue,
|
||||
Color.hbSuccess,
|
||||
Color(red: 0.6, green: 0.4, blue: 0.8),
|
||||
Color(red: 0.9, green: 0.5, blue: 0.3),
|
||||
Color(red: 0.3, green: 0.7, blue: 0.7),
|
||||
]
|
||||
|
||||
private var hashBytes: [UInt8] {
|
||||
let data = withUnsafeBytes(of: id.uuid) { Data($0) }
|
||||
return Array(SHA256.hash(data: data))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let bytes = hashBytes
|
||||
Canvas { context, size in
|
||||
let cellW = size.width / CGFloat(gridSize)
|
||||
let cellH = size.height / CGFloat(gridSize)
|
||||
|
||||
for row in 0 ..< gridSize {
|
||||
for col in 0 ..< gridSize {
|
||||
let byteIndex = (row * gridSize + col) % bytes.count
|
||||
let byte = bytes[byteIndex]
|
||||
let colorIndex = Int(byte) % palette.count
|
||||
let brightness = Double(bytes[(byteIndex + 1) % bytes.count]) / 255.0
|
||||
let opacity = 0.5 + brightness * 0.5
|
||||
|
||||
let rect = CGRect(
|
||||
x: CGFloat(col) * cellW,
|
||||
y: CGFloat(row) * cellH,
|
||||
width: cellW,
|
||||
height: cellH
|
||||
)
|
||||
context.fill(Path(rect), with: .color(palette[colorIndex].opacity(opacity)))
|
||||
}
|
||||
}
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sync Status Dot
|
||||
|
||||
struct SyncStatusDot: View {
|
||||
|
||||
@ -32,9 +32,9 @@ struct ConnectionStatusView: View {
|
||||
.navigationTitle("Connection Status")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
.foregroundStyle(Color.hbTextSecondary)
|
||||
.foregroundStyle(Color.hbBitcoinOrange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,10 +7,6 @@ private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "hellbend
|
||||
|
||||
struct SettingsView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Query private var wallets: [WalletProfile]
|
||||
@State private var viewModel = WalletManagerViewModel()
|
||||
@State private var walletToDelete: WalletProfile?
|
||||
@State private var showAddWallet = false
|
||||
@State private var showLogExport = false
|
||||
|
||||
var body: some View {
|
||||
@ -25,65 +21,6 @@ struct SettingsView: View {
|
||||
.padding(.bottom, 4)
|
||||
|
||||
List {
|
||||
// Wallets
|
||||
Section("Wallets") {
|
||||
ForEach(wallets) { wallet in
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
if !wallet.isActive {
|
||||
viewModel.setActiveWallet(wallet, allWallets: wallets, modelContext: modelContext)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: wallet.isActive ? "checkmark.circle.fill" : "circle")
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(wallet.isActive ? Color.hbBitcoinOrange : Color.hbTextSecondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
NavigationLink(destination: WalletInfoView(wallet: wallet)) {
|
||||
HStack(spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(wallet.name)
|
||||
.font(.hbHeadline)
|
||||
.foregroundStyle(Color.hbTextPrimary)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text(wallet.multisigDescription)
|
||||
.font(.hbMono(12))
|
||||
.foregroundStyle(Color.hbTextSecondary)
|
||||
|
||||
NetworkBadge(network: wallet.bitcoinNetwork)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button(role: .destructive) {
|
||||
walletToDelete = wallet
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color.hbSurface)
|
||||
}
|
||||
|
||||
if wallets.isEmpty {
|
||||
Text("No wallets configured")
|
||||
.foregroundStyle(Color.hbTextSecondary)
|
||||
.listRowBackground(Color.hbSurface)
|
||||
}
|
||||
|
||||
Button(action: { showAddWallet = true }) {
|
||||
Label("Add Wallet", systemImage: "plus.circle")
|
||||
.font(.hbBody(15))
|
||||
.foregroundStyle(Color.hbBitcoinOrange)
|
||||
}
|
||||
.listRowBackground(Color.hbSurface)
|
||||
}
|
||||
|
||||
// Security
|
||||
AppLockSettingsSection()
|
||||
|
||||
@ -125,24 +62,6 @@ struct SettingsView: View {
|
||||
}
|
||||
.background(Color.hbBackground)
|
||||
.navigationTitle("")
|
||||
.sheet(isPresented: $showAddWallet) {
|
||||
SetupWizardView(canDismiss: true)
|
||||
.interactiveDismissDisabled()
|
||||
}
|
||||
.alert("Delete Wallet?", isPresented: .init(
|
||||
get: { walletToDelete != nil },
|
||||
set: { if !$0 { walletToDelete = nil } }
|
||||
)) {
|
||||
Button("Delete", role: .destructive) {
|
||||
if let wallet = walletToDelete {
|
||||
viewModel.deleteWallet(wallet, modelContext: modelContext)
|
||||
}
|
||||
walletToDelete = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) { walletToDelete = nil }
|
||||
} message: {
|
||||
Text("This will permanently delete \"\(walletToDelete?.name ?? "")\" and all its data.")
|
||||
}
|
||||
.sheet(isPresented: $showLogExport) {
|
||||
LogExportSheet()
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@ struct WalletInfoView: View {
|
||||
@State private var showEditCosigners = false
|
||||
@State private var isEditingName = false
|
||||
@State private var editedName: String = ""
|
||||
@FocusState private var nameFieldFocused: Bool
|
||||
@State private var electrumHostText: String = ""
|
||||
@State private var electrumPortText: String = ""
|
||||
@State private var isTestingConnection = false
|
||||
@ -54,7 +55,9 @@ struct WalletInfoView: View {
|
||||
.foregroundStyle(Color.hbTextPrimary)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.frame(maxWidth: 180)
|
||||
.focused($nameFieldFocused)
|
||||
.onSubmit { saveName() }
|
||||
.onAppear { nameFieldFocused = true }
|
||||
Button(action: saveName) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(Color.hbSuccess)
|
||||
|
||||
@ -10,6 +10,13 @@ struct TransactionListView: View {
|
||||
@Query private var walletLabels: [WalletLabel]
|
||||
@Query private var frozenUTXOs: [FrozenUTXO]
|
||||
@State private var viewModel = TransactionListViewModel()
|
||||
@State private var walletManager = WalletManagerViewModel()
|
||||
@State private var showWalletPicker = false
|
||||
@State private var walletPickerEditMode = false
|
||||
@State private var showAddWallet = false
|
||||
@State private var showWalletInfo = false
|
||||
@State private var walletToEdit: WalletProfile?
|
||||
@State private var walletToDelete: WalletProfile?
|
||||
@State private var showConnectionStatus = false
|
||||
@State private var showDashboard = false
|
||||
@State private var showImportFilePicker = false
|
||||
@ -33,6 +40,10 @@ struct TransactionListView: View {
|
||||
FiatPriceService.shared
|
||||
}
|
||||
|
||||
private var activeWalletName: String {
|
||||
wallets.first(where: { $0.isActive })?.name ?? viewModel.walletName
|
||||
}
|
||||
|
||||
private var isPrivate: Bool {
|
||||
wallets.first(where: { $0.isActive })?.privacyMode ?? false
|
||||
}
|
||||
@ -188,25 +199,17 @@ struct TransactionListView: View {
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
// Wallet status header
|
||||
VStack(spacing: 8) {
|
||||
// Wallet hero header
|
||||
VStack(spacing: 12) {
|
||||
// Wallet selector + menu row
|
||||
HStack {
|
||||
Button(action: { showConnectionStatus = true }) {
|
||||
SyncStatusDot(state: viewModel.syncState)
|
||||
}
|
||||
|
||||
Text(viewModel.walletName)
|
||||
.font(.hbHeadline)
|
||||
.foregroundStyle(Color.hbTextPrimary)
|
||||
|
||||
NetworkBadge(network: viewModel.network)
|
||||
|
||||
Spacer()
|
||||
|
||||
Menu {
|
||||
Button(action: { showDashboard = true }) {
|
||||
Label("Dashboard", systemImage: "chart.bar.xaxis")
|
||||
}
|
||||
Button(action: { showWalletInfo = true }) {
|
||||
Label("Wallet Info", systemImage: "info.circle")
|
||||
}
|
||||
Menu {
|
||||
Button(action: { showImportFilePicker = true }) {
|
||||
Label("Labels File Import", systemImage: "square.and.arrow.down")
|
||||
@ -222,61 +225,79 @@ struct TransactionListView: View {
|
||||
Label("Wallet Labels", systemImage: "tag")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
Image(systemName: "ellipsis")
|
||||
.font(.system(size: 20))
|
||||
.foregroundStyle(Color.hbTextSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
HStack(alignment: .center) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
if isPrivate {
|
||||
Text(Constants.privacyText())
|
||||
.font(.hbAmountLarge)
|
||||
.foregroundStyle(Color.hbTextPrimary)
|
||||
} else if fiatEnabled, fiatPrimary, let fiatStr = fiatService.formattedSatsToFiat(viewModel.balance) {
|
||||
Text(fiatStr)
|
||||
.font(.hbAmountLarge)
|
||||
.foregroundStyle(Color.hbTextPrimary)
|
||||
Text(viewModel.balance.formattedSats)
|
||||
.font(.hbBody(14))
|
||||
.foregroundStyle(Color.hbTextSecondary)
|
||||
} else {
|
||||
Text(viewModel.balance.formattedSats)
|
||||
.font(.hbAmountLarge)
|
||||
.foregroundStyle(Color.hbTextPrimary)
|
||||
if fiatEnabled, let fiatStr = fiatService.formattedSatsToFiat(viewModel.balance) {
|
||||
Text(fiatStr)
|
||||
.font(.hbBody(14))
|
||||
.foregroundStyle(Color.hbTextSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onLongPressGesture {
|
||||
togglePrivacyMode()
|
||||
}
|
||||
.onTapGesture(count: 2) {
|
||||
if fiatEnabled { fiatPrimary.toggle() }
|
||||
.frame(width: 44, height: 44)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if fiatEnabled {
|
||||
Button(action: { fiatPrimary.toggle() }) {
|
||||
Image(systemName: "arrow.up.arrow.down")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
// Wallet picker
|
||||
Button(action: { showWalletPicker.toggle() }) {
|
||||
HStack(spacing: 8) {
|
||||
if let walletID {
|
||||
WalletIdenticon(id: walletID)
|
||||
.frame(width: 24, height: 24)
|
||||
}
|
||||
Text(activeWalletName)
|
||||
.font(.hbHeadline)
|
||||
.foregroundStyle(Color.hbTextPrimary)
|
||||
Image(systemName: showWalletPicker ? "chevron.up" : "chevron.down")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(Color.hbTextSecondary)
|
||||
.padding(8)
|
||||
.background(Color.hbSurfaceElevated)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 22)
|
||||
.strokeBorder(Color.hbBorder, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Connection status
|
||||
Button(action: { showConnectionStatus = true }) {
|
||||
SyncStatusDot(state: viewModel.syncState)
|
||||
}
|
||||
}
|
||||
|
||||
// Balance
|
||||
VStack(spacing: 2) {
|
||||
if isPrivate {
|
||||
Text(Constants.privacyText())
|
||||
.font(.hbAmountLarge)
|
||||
.foregroundStyle(Color.hbTextPrimary)
|
||||
} else if fiatEnabled, fiatPrimary, let fiatStr = fiatService.formattedSatsToFiat(viewModel.balance) {
|
||||
Text(fiatStr)
|
||||
.font(.hbAmountLarge)
|
||||
.foregroundStyle(Color.hbTextPrimary)
|
||||
Text(viewModel.balance.formattedSats)
|
||||
.font(.hbBody(14))
|
||||
.foregroundStyle(Color.hbTextSecondary)
|
||||
} else {
|
||||
Text(viewModel.balance.formattedSats)
|
||||
.font(.hbAmountLarge)
|
||||
.foregroundStyle(Color.hbTextPrimary)
|
||||
if fiatEnabled, let fiatStr = fiatService.formattedSatsToFiat(viewModel.balance) {
|
||||
Text(fiatStr)
|
||||
.font(.hbBody(14))
|
||||
.foregroundStyle(Color.hbTextSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onLongPressGesture {
|
||||
togglePrivacyMode()
|
||||
}
|
||||
.onTapGesture(count: 2) {
|
||||
if fiatEnabled { fiatPrimary.toggle() }
|
||||
}
|
||||
|
||||
// Info row
|
||||
HStack {
|
||||
Text(viewModel.multisigDescription + " multisig")
|
||||
.font(.hbLabel())
|
||||
.foregroundStyle(Color.hbTextSecondary)
|
||||
NetworkBadge(network: viewModel.network)
|
||||
|
||||
Spacer()
|
||||
|
||||
@ -294,13 +315,29 @@ struct TransactionListView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.hbCard()
|
||||
.padding(16)
|
||||
.background(Color.hbHeroBackground)
|
||||
|
||||
// Transactions section header
|
||||
HStack {
|
||||
Text("Transactions")
|
||||
.font(.hbHeadline)
|
||||
.foregroundStyle(Color.hbTextPrimary)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 12)
|
||||
.padding(.bottom, 4)
|
||||
|
||||
transactionContent
|
||||
}
|
||||
.background(Color.hbBackground)
|
||||
.overlay { walletPickerOverlay }
|
||||
.navigationTitle("")
|
||||
.onDisappear {
|
||||
walletPickerEditMode = false
|
||||
showWalletPicker = false
|
||||
}
|
||||
.refreshable {
|
||||
await viewModel.refresh()
|
||||
}
|
||||
@ -310,6 +347,33 @@ struct TransactionListView: View {
|
||||
.sheet(isPresented: $showDashboard) {
|
||||
WalletDashboardView()
|
||||
}
|
||||
.sheet(isPresented: $showAddWallet, onDismiss: {
|
||||
if let active = wallets.first(where: { $0.isActive }),
|
||||
bitcoinService.currentProfile?.id != active.id
|
||||
{
|
||||
viewModel.clearState()
|
||||
bitcoinService.unloadWallet()
|
||||
viewModel.loadActiveWallet(from: wallets)
|
||||
}
|
||||
}) {
|
||||
SetupWizardView(canDismiss: true)
|
||||
.interactiveDismissDisabled()
|
||||
}
|
||||
.sheet(isPresented: $showWalletInfo, onDismiss: {
|
||||
walletToEdit = nil
|
||||
}) {
|
||||
if let wallet = walletToEdit ?? wallets.first(where: { $0.isActive }) {
|
||||
NavigationStack {
|
||||
WalletInfoView(wallet: wallet)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") { showWalletInfo = false }
|
||||
.foregroundStyle(Color.hbBitcoinOrange)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.fileImporter(
|
||||
isPresented: $showImportFilePicker,
|
||||
allowedContentTypes: [UTType(filenameExtension: "jsonl") ?? .plainText],
|
||||
@ -317,6 +381,20 @@ struct TransactionListView: View {
|
||||
) { result in
|
||||
importLabelsFromFile(result: result)
|
||||
}
|
||||
.alert("Delete Wallet?", isPresented: .init(
|
||||
get: { walletToDelete != nil },
|
||||
set: { if !$0 { walletToDelete = nil } }
|
||||
)) {
|
||||
Button("Delete", role: .destructive) {
|
||||
if let wallet = walletToDelete {
|
||||
walletManager.deleteWallet(wallet, modelContext: modelContext)
|
||||
}
|
||||
walletToDelete = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) { walletToDelete = nil }
|
||||
} message: {
|
||||
Text("Are you sure you want to delete \"\(walletToDelete?.name ?? "")\"? This cannot be undone. You can re-import using your output descriptor.")
|
||||
}
|
||||
.alert("Import Labels", isPresented: $showImportResult) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
@ -393,6 +471,8 @@ struct TransactionListView: View {
|
||||
Task { await fiatService.fetchRatesIfNeeded() }
|
||||
}
|
||||
} else {
|
||||
walletPickerEditMode = false
|
||||
showWalletPicker = false
|
||||
bitcoinService.stopAutoSync()
|
||||
}
|
||||
}
|
||||
@ -425,6 +505,129 @@ struct TransactionListView: View {
|
||||
generator.impactOccurred()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var walletPickerOverlay: some View {
|
||||
if showWalletPicker {
|
||||
Color.black.opacity(0.35)
|
||||
.ignoresSafeArea()
|
||||
.onTapGesture {
|
||||
walletPickerEditMode = false
|
||||
showWalletPicker = false
|
||||
}
|
||||
|
||||
VStack {
|
||||
VStack(spacing: 0) {
|
||||
// Header
|
||||
HStack {
|
||||
Button(action: {
|
||||
walletPickerEditMode.toggle()
|
||||
}) {
|
||||
Text(walletPickerEditMode ? "Done" : "Edit")
|
||||
.font(.hbBody(15))
|
||||
.foregroundStyle(walletPickerEditMode ? Color.hbSuccess : Color.hbTextSecondary)
|
||||
}
|
||||
Spacer()
|
||||
Text("Wallets")
|
||||
.font(.hbHeadline)
|
||||
.foregroundStyle(Color.hbTextPrimary)
|
||||
Spacer()
|
||||
Button(action: {
|
||||
walletPickerEditMode = false
|
||||
showWalletPicker = false
|
||||
showAddWallet = true
|
||||
}) {
|
||||
Text("Add")
|
||||
.font(.hbBody(15))
|
||||
.foregroundStyle(Color.hbBitcoinOrange)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
|
||||
Divider()
|
||||
.background(Color.hbBorder)
|
||||
|
||||
ForEach(Array(wallets.enumerated()), id: \.element.id) { index, wallet in
|
||||
HStack(spacing: 12) {
|
||||
if walletPickerEditMode {
|
||||
Image(systemName: "minus.circle.fill")
|
||||
.font(.system(size: 20))
|
||||
.foregroundStyle(Color.hbError)
|
||||
.onTapGesture {
|
||||
walletToDelete = wallet
|
||||
}
|
||||
}
|
||||
|
||||
WalletIdenticon(id: wallet.id)
|
||||
.frame(width: 32, height: 32)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.strokeBorder(Color.hbBitcoinOrange, lineWidth: wallet.isActive ? 2 : 0)
|
||||
)
|
||||
VStack(spacing: 4) {
|
||||
NetworkBadge(network: wallet.bitcoinNetwork)
|
||||
Text(wallet.multisigDescription)
|
||||
.font(.hbLabel())
|
||||
.foregroundStyle(Color.hbTextSecondary)
|
||||
}
|
||||
.fixedSize()
|
||||
Text(wallet.name)
|
||||
.font(.hbBody())
|
||||
.foregroundStyle(Color.hbTextPrimary)
|
||||
Spacer()
|
||||
|
||||
if walletPickerEditMode {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(Color.hbTextSecondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 15)
|
||||
.background(wallet.isActive ? Color.hbBitcoinOrange.opacity(0.08) : Color.clear)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
if walletPickerEditMode {
|
||||
walletToEdit = wallet
|
||||
walletPickerEditMode = false
|
||||
showWalletPicker = false
|
||||
showWalletInfo = true
|
||||
} else {
|
||||
guard !wallet.isActive else {
|
||||
showWalletPicker = false
|
||||
return
|
||||
}
|
||||
viewModel.clearState()
|
||||
walletManager.setActiveWallet(wallet, allWallets: wallets, modelContext: modelContext)
|
||||
showWalletPicker = false
|
||||
}
|
||||
}
|
||||
.onLongPressGesture {
|
||||
walletPickerEditMode = true
|
||||
}
|
||||
if index < wallets.count - 1 {
|
||||
Divider()
|
||||
.background(Color.hbBorder)
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(Color.hbSurfaceElevated)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.strokeBorder(Color.hbBorder, lineWidth: 0.5)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.25), radius: 12, y: 4)
|
||||
.padding(.horizontal, 35)
|
||||
.padding(.top, 60)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.transition(.opacity)
|
||||
.animation(.easeInOut(duration: 0.15), value: showWalletPicker)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var transactionContent: some View {
|
||||
if viewModel.transactions.isEmpty, viewModel.isLoading || viewModel.syncState.isSyncing {
|
||||
|
||||
@ -52,6 +52,8 @@ struct SetupWizardView: View {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(Color.hbTextSecondary)
|
||||
.frame(width: 44, height: 44)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,7 +21,7 @@ struct WalletNameView: View {
|
||||
.font(.hbLabel())
|
||||
.foregroundStyle(Color.hbTextSecondary)
|
||||
|
||||
TextField("My Multisig Wallet", text: $viewModel.walletName)
|
||||
TextField("My Wallet", text: $viewModel.walletName)
|
||||
.font(.hbBody(18))
|
||||
.padding(14)
|
||||
.background(Color.hbSurfaceElevated)
|
||||
|
||||
@ -67,7 +67,7 @@ final class hellbenderUITests: XCTestCase {
|
||||
XCTAssertTrue(nameTitle.waitForExistence(timeout: 3), "Wallet name screen should appear")
|
||||
|
||||
// Type a wallet name into the text field
|
||||
let nameField = app.textFields["My Multisig Wallet"]
|
||||
let nameField = app.textFields["My Wallet"]
|
||||
XCTAssertTrue(nameField.waitForExistence(timeout: 3), "Wallet name text field should exist")
|
||||
nameField.tap()
|
||||
nameField.typeText("UI Test Wallet")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user