Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
71 commits
Select commit Hold shift + click to select a range
c8863dc
feat(wallet): add the Advanced mode switch to Settings
jeanpierreroma Aug 17, 2026
aac57fd
feat(ui): let a menu row's info icon be a button, and explain Advance…
jeanpierreroma Aug 17, 2026
baa82dd
refactor(ui): draw the settings rows with DashUIKit's MenuItem
jeanpierreroma Aug 17, 2026
e8a5378
refactor(ui): move ActivityView out of the settings screen
jeanpierreroma Aug 17, 2026
409f58b
feat(ui): explain Advanced mode in a bottom sheet
jeanpierreroma Aug 18, 2026
5aa024e
refactor(ui): drop the app's copy of XmarkIcon
jeanpierreroma Aug 18, 2026
f0b4ac6
feat(ui): use the design system's info mark on the Advanced mode row
jeanpierreroma Aug 18, 2026
675c037
refactor(ui): lift the settings sheet and alerts into Components
jeanpierreroma Aug 18, 2026
e65a26a
refactor(ui): take SheetFeature from DashUIKit
jeanpierreroma Aug 18, 2026
a0f87be
refactor(ui): split the internal transfer screen into Components
jeanpierreroma Aug 18, 2026
9fec777
refactor(ui): rebuild the transfer timing sheet on DashUIKit
jeanpierreroma Aug 18, 2026
3b54153
test(ui): add previews to the internal transfer screens
jeanpierreroma Aug 19, 2026
6221931
feat(ui): give SegmentedControl an icon variant
jeanpierreroma Aug 19, 2026
b766d19
fix(ui): stop a custom toast icon from filling the toast
jeanpierreroma Aug 19, 2026
c354f75
refactor(ui): name the timing sheet's icons through DashIcon
jeanpierreroma Aug 19, 2026
ef4a5b2
feat(payments): open the landing on destination cards inside a tab
jeanpierreroma Aug 19, 2026
d5e1cdc
feat(receive): show the requested amount on a SwiftUI sheet
jeanpierreroma Aug 20, 2026
c681ba0
build: add the SwiftUI request-amount screen to both targets
jeanpierreroma Aug 20, 2026
c2e239a
refactor(ui): take the keypad panel and the navigation bar from DashU…
jeanpierreroma Aug 20, 2026
ef4a630
refactor(ui): split SpecifyAmountView out of its controller
jeanpierreroma Aug 20, 2026
e58c0c3
fix(receive): raise the copied toast over the sheet, not inside it
jeanpierreroma Aug 20, 2026
3d764da
feat(send): route every source through the same amount step
jeanpierreroma Aug 20, 2026
ee7268e
feat(ui): take the two inline notes out of the transfer form
jeanpierreroma Aug 21, 2026
4270014
feat(ui): draw the transfer endpoints on the design system's Converte…
jeanpierreroma Aug 21, 2026
0f433bd
refactor(ui): break up the internal transfer confirm sheet
jeanpierreroma Aug 21, 2026
77979d5
feat(payments): move credits out of the identity, not just into it
jeanpierreroma Aug 22, 2026
420d099
refactor(ui): give the endpoint picker its own file and the design sy…
jeanpierreroma Aug 22, 2026
bcdc02f
feat(payments): run an internal transfer past the sheet that starts it
jeanpierreroma Aug 23, 2026
007ed66
fix(ui): centre the transfer preview in the space it has
jeanpierreroma Aug 23, 2026
db525a5
fix(ui): hide the progress HUD, not whatever is on top of it
jeanpierreroma Aug 23, 2026
8e2da50
refactor(ui): draw the pinned transfer endpoints on ConverterCard too
jeanpierreroma Aug 23, 2026
7ddfcc5
fix(ui): line the landing's picker cards up with the selector
jeanpierreroma Aug 23, 2026
9d45485
fix(ui): keep the transfer form top-aligned before an amount is typed
jeanpierreroma Aug 23, 2026
9af8a58
fix(payments): stop the payments tab from re-showing its navigation bar
jeanpierreroma Aug 23, 2026
921fff4
feat(ui): give each payments tab its own arrow
jeanpierreroma Aug 23, 2026
9a759f6
build: register the confirm-flow files with both targets
jeanpierreroma Aug 23, 2026
041e2b1
Merge remote-tracking branch 'origin/develop' into feat/advanced-mode
jeanpierreroma Aug 23, 2026
4f818b1
Merge remote-tracking branch 'origin/develop' into feat/internal-tran…
jeanpierreroma Aug 23, 2026
1945bc7
Merge branch 'feat/advanced-mode' into feat/internal-transfer-redesign
jeanpierreroma Aug 23, 2026
5d73106
Merge pull request #1041 (attended receive) into the transfer redesign
jeanpierreroma Aug 23, 2026
21d347d
fix(payments): set the receipt's detail flag in the preview initializer
jeanpierreroma Aug 23, 2026
42d3b08
feat(menu): let Advanced mode gate the rows it was added for
jeanpierreroma Aug 23, 2026
a7f090f
Merge branch 'feat/advanced-mode' into feat/internal-transfer-redesign
jeanpierreroma Aug 23, 2026
6afeda5
Merge branch 'feat/advanced-mode' into feat/internal-transfer-redesign
jeanpierreroma Aug 23, 2026
d301b3d
Merge branch 'wip/with-1041' into feat/internal-transfer-redesign
jeanpierreroma Aug 23, 2026
16f2613
Merge remote-tracking branch 'origin/develop' into feat/internal-tran…
jeanpierreroma Aug 23, 2026
8fbe42d
feat(payments): let Advanced mode decide which balances a transfer re…
jeanpierreroma Aug 23, 2026
852b4a9
fix(send): stop Transparent from asking for the amount twice
jeanpierreroma Aug 23, 2026
0522d10
fix(receive): make the attended receipt work in the redesigned landing
jeanpierreroma Aug 23, 2026
b32e2fa
fix(receive): keep watching while the user is on Specify Amount
jeanpierreroma Aug 23, 2026
039d477
fix(receive): return from Specify Amount too, not just from its sheet
jeanpierreroma Aug 23, 2026
b2642d1
fix(receive): pop to the controller the navigation stack actually holds
jeanpierreroma Aug 23, 2026
7dfc410
fix(send): show the transaction after a send that had no legacy amoun…
jeanpierreroma Aug 23, 2026
93423a2
fix(send): leave for the history when the success screen is closed
jeanpierreroma Aug 23, 2026
37b311b
refactor(send): draw the payment confirmation on BottomSheet
jeanpierreroma Aug 23, 2026
dce719e
refactor(ui): draw the confirmation rows and the privacy tip from the…
jeanpierreroma Aug 23, 2026
bc06241
fix(send): give the confirmation its Dash symbols and a single-line a…
jeanpierreroma Aug 23, 2026
379044c
fix(send): show the network fee at full precision
jeanpierreroma Aug 23, 2026
e0ac813
build: 9.1.0 (2)
jeanpierreroma Aug 23, 2026
a6a33f8
fix(home): declare isPreviewMode in every configuration
jeanpierreroma Aug 23, 2026
ea0a29a
fix(wallet): address the review findings on the transfer and receive …
jeanpierreroma Aug 23, 2026
2ec89b8
refactor(wallet): take the shielded recovery logic out of its view
jeanpierreroma Aug 23, 2026
ee36941
test(wallet): give the Advanced mode sheet a view model and cover its…
jeanpierreroma Aug 23, 2026
edbfd19
fix(ui): pin the transfer form to the top, not halfway down it
jeanpierreroma Aug 23, 2026
9fa124f
build: 9.1.0 (3)
jeanpierreroma Aug 23, 2026
5f17b40
feat(payments): rework the Internal and Send tabs to the revised design
jeanpierreroma Aug 24, 2026
b402c87
feat(payments): give the landing its own chrome instead of the tab bar
jeanpierreroma Aug 24, 2026
60ad3be
feat(wallet): put the balance breakdown and receive networks behind A…
jeanpierreroma Aug 24, 2026
1fbe53b
fix(wallet): restore the tab bar when the tab layout is rebuilt
jeanpierreroma Aug 24, 2026
476da44
fix(payments): stop the tab clip cutting off the keypad panel
jeanpierreroma Aug 25, 2026
057d354
feat(payments): rebuild the Receive tab's network picker and address row
jeanpierreroma Aug 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
316 changes: 259 additions & 57 deletions DashWallet.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions DashWallet/Sources/Application/App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,10 @@ private let kFiatCurrencyDidChange = "FiatCurrencyDidChange"

extension Notification.Name {
static let fiatCurrencyDidChange = Notification.Name(kFiatCurrencyDidChange)
/// `DWGlobalOptions.advancedModeEnabled` was flipped. Posted by whoever
/// writes it, so a screen already on display can rebuild rather than show
/// the value it happened to read when it appeared.
static let advancedModeDidChange = Notification.Name("org.dash.advanced-mode-did-change")
static let willRequestOSPermission = Notification.Name("org.dash.will-request-permission-notification")
static let didRequestOSPermission = Notification.Name("org.dash.did-request-permission-notification")
static let applicationTerminationRequest = Notification.Name("DWApplicationTerminationRequestNotification")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,34 @@ final class DWIdentityAuthorizer {
}
}

/// True while a caller has already run this gate for the work about to
/// execute, so the executor's own `authorize()` must not ask a second
/// time.
///
/// Task-local rather than a stored flag: it covers exactly the work run
/// inside `preauthorized(_:)` and nothing else, so every other entry point
/// (the recovery sheet, the Send screen, a profile top-up) still prompts
/// for itself, and it cannot be left set by a transfer that died.
@TaskLocal static var isPreauthorized = false

/// Runs `body` with this gate already satisfied. The caller must have
/// awaited `authorize()` successfully first — this only suppresses the
/// SECOND prompt, it never skips authentication.
///
/// Why anyone would: the gate has to be settled before the screen that
/// asked for it goes away, and the executors raise it from deep inside
/// work that outlives that screen.
static func preauthorized<T>(_ body: () async -> T) async -> T {
await $isPreauthorized.withValue(true, operation: body)
}

@MainActor
func authorize() async throws {
if Self.isPreauthorized {
Self.logger.info("🪪 IDENTITY-AUTH :: already authorized for this operation — not prompting again")
return
}

let biometricEnabled = DWGlobalOptions.sharedInstance().biometricAuthEnabled
Self.logger.info("🪪 IDENTITY-AUTH :: authenticating via AuthenticationGate (biometric=\(biometricEnabled, privacy: .public))")
let outcome = await AuthenticationGate.authenticate(biometric: biometricEnabled)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,13 +158,26 @@ enum PlatformPaymentIdentityFundingPolicy {
fundingDuffs: fundingDuffs)
}

/// Builds inputs in PlatformAddress/BTreeMap order. Rust's derived order
/// compares the address variant first (P2PKH before P2SH), then its
/// 20-byte hash.
static func makeInputs(
candidates: [Candidate],
targetCredits: UInt64
) throws -> [ManagedPlatformWallet.IdentityAddressInput] {
/// Saturating credit total of `candidates`.
private static func totalCredits(of candidates: [Candidate]) -> UInt64 {
candidates.reduce(UInt64(0)) {
let (sum, overflow) = $0.addingReportingOverflow($1.balance)
return overflow ? .max : sum
}
}

/// The candidates `makeInputs` can actually plan from, in
/// PlatformAddress/BTreeMap order (Rust's derived order compares the
/// address variant first — P2PKH before P2SH — then the 20-byte hash),
/// plus their saturating credit total.
///
/// Any selected address before the fee source would become BTreeMap
/// input 0 itself, so leading addresses that cannot retain the reserve
/// are skipped and the plan draws exclusively from the viable suffix.
/// `nil` when no address can be the fee source.
private static func usableSelection(
candidates: [Candidate]
) -> (usable: [Candidate], availableCredits: UInt64)? {
let sorted = candidates
.filter { $0.balance > 0 }
.sorted { lhs, rhs in
Expand All @@ -173,29 +186,40 @@ enum PlatformPaymentIdentityFundingPolicy {
}
return lhs.hash.lexicographicallyPrecedes(rhs.hash)
}
let totalAvailable = sorted.reduce(UInt64(0)) {
let (sum, overflow) = $0.addingReportingOverflow($1.balance)
return overflow ? .max : sum
}
guard let feeSourceIndex = sorted.firstIndex(where: {
$0.balance > feeHeadroomCredits
}) else { return nil }
let usable = Array(sorted[feeSourceIndex...])
return (usable, totalCredits(of: usable))
}

/// Largest funding amount `makeInputs` can plan from `candidates`, in
/// whole duffs (floored, so the ceiling never overstates what the
/// credit-denominated planner accepts). Input 0 contributes
/// `balance − reserve` and every later input its full balance, so the
/// ceiling is the usable total minus the retained fee headroom. 0 when
/// no address qualifies as the fee source.
static func maxFundableDuffs(candidates: [Candidate]) -> UInt64 {
guard let (_, availableCredits) = usableSelection(candidates: candidates),
availableCredits > feeHeadroomCredits
else { return 0 }
return (availableCredits - feeHeadroomCredits) / creditsPerDuff
}

/// Builds inputs from the usable selection above.
static func makeInputs(
candidates: [Candidate],
targetCredits: UInt64
) throws -> [ManagedPlatformWallet.IdentityAddressInput] {
let required = {
let (sum, overflow) = targetCredits.addingReportingOverflow(feeHeadroomCredits)
return overflow ? UInt64.max : sum
}()

// Any selected address before the fee source would become BTreeMap
// input 0 itself. Skip leading addresses that cannot retain the
// reserve, then plan exclusively from the viable suffix.
guard let feeSourceIndex = sorted.firstIndex(where: {
$0.balance > feeHeadroomCredits
}) else {
guard let (usable, usableAvailable) = usableSelection(candidates: candidates) else {
throw PlanningError.insufficient(
required: required,
available: totalAvailable)
}
let usable = Array(sorted[feeSourceIndex...])
let usableAvailable = usable.reduce(UInt64(0)) {
let (sum, overflow) = $0.addingReportingOverflow($1.balance)
return overflow ? .max : sum
available: totalCredits(of: candidates))
}
guard usableAvailable >= required else {
throw PlanningError.insufficient(
Expand Down
8 changes: 8 additions & 0 deletions DashWallet/Sources/Models/DWGlobalOptions.h
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ NS_ASSUME_NONNULL_BEGIN
@property (nonatomic, assign) BOOL balanceHidden;
@property (nonatomic, assign) BOOL tapToHideBalanceShown;

/// Opt-in to the advanced surfaces across the app. Off by default: the
/// screens it unlocks assume the user knows what a UTXO or a derivation path
/// is, and showing them unasked is how an ordinary wallet stops looking
/// ordinary. Read it anywhere, but observe
/// `Notification.Name.advancedModeDidChange` rather than caching the value —
/// it can be flipped from Settings while any screen is on display.
@property (nonatomic, assign) BOOL advancedModeEnabled;

@property (nonatomic, assign) BOOL shouldDisplayOnboarding;
@property (nonatomic, assign) BOOL shouldDisplayReclassifyYourTransactionsFlow;
@property (nullable, nonatomic, strong) NSDate *dateReclassifyYourTransactionsFlowActivated;
Expand Down
2 changes: 2 additions & 0 deletions DashWallet/Sources/Models/DWGlobalOptions.m
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ @implementation DWGlobalOptions
@dynamic autoLockAppInterval;
@dynamic shortcuts;
@dynamic balanceHidden;
@dynamic advancedModeEnabled;
@dynamic tapToHideBalanceShown;
@dynamic shouldDisplayOnboarding;
@dynamic paymentsScreenCurrentTab;
Expand Down Expand Up @@ -229,6 +230,7 @@ - (void)restoreToDefaults {
self.shortcuts = nil;
self.localNotificationsEnabled = YES;
self.balanceHidden = NO;
self.advancedModeEnabled = NO;
self.tapToHideBalanceShown = NO;
self.resyncingWallet = NO;
self.selectedPaymentCurrency = DWPaymentCurrencyDash;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,6 @@ struct TransferAmountView<ViewModel: TransferAmountViewModelProtocol>: View {
inProgress: viewModel.isProcessing,
actionHandler: { viewModel.transfer() }
)
.frame(maxWidth: .infinity, maxHeight: 320)
.background(Color.dash.secondaryBackground)
.clipShape(.rect(cornerRadius: 20))
.background(Color.dash.secondaryBackground, ignoresSafeAreaEdges: .bottom)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -807,7 +807,6 @@ struct PayContactSheet: View {
inProgress: isSending,
actionHandler: { pay() }
)
.padding(.top, 8)
}

/// Fiat equivalent of what is typed, for the secondary line. Empty while
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,16 @@ final class IdentityTopUpViewModel: ObservableObject {
case platform
case transparent

/// The funding source that spends `network`'s balance — the internal
/// transfer screen's bridge from its FROM side to this executor.
init(spending network: ChainNetwork) {
switch network {
case .core: self = .transparent
case .platform: self = .platform
case .shielded: self = .shielded
}
}

var title: String {
switch self {
case .shielded: return NSLocalizedString("Shielded", comment: "Shielded activity: funds moved into the private shielded balance")
Expand Down Expand Up @@ -439,10 +449,15 @@ final class IdentityTopUpViewModel: ObservableObject {
/// unshield minimum (shielded route only, via the pure
/// `estimateShieldedFee` FFI) plus the observed top-up transition fee.
/// The transparent route also pays a small Core miner fee on top.
static func estimatedFeeCredits(source: FundingSource) -> UInt64 {
/// `nil` when the shielded route's unshield estimate is unavailable —
/// callers show "no number" rather than only the transition fee.
static func estimatedFeeCredits(source: FundingSource) -> UInt64? {
let unshieldFee: UInt64
if source == .shielded {
unshieldFee = (try? PlatformWalletManager.estimateShieldedFee(kind: .unshield, numActions: 2)) ?? 0
guard let estimate = try? PlatformWalletManager.estimateShieldedFee(kind: .unshield, numActions: 2) else {
return nil
}
unshieldFee = estimate
} else {
unshieldFee = 0
}
Expand Down Expand Up @@ -828,7 +843,11 @@ struct IdentityTopUpSheet: View {
}

private var estimatedFeeText: String {
let credits = IdentityTopUpViewModel.estimatedFeeCredits(source: source)
guard let credits = IdentityTopUpViewModel.estimatedFeeCredits(source: source) else {
// Shielded route with the unshield estimate unavailable: show no
// number rather than one that omits the larger fee component.
return "—"
}
let duffs = credits / 1000
return String.localizedStringWithFormat(
NSLocalizedString("~%@ DASH (≈ %@)", comment: "DashPay: estimated network fee — DASH amount, then its local-currency equivalent"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,9 +140,6 @@ struct DashSpendSinglePanel: View {
inProgress: inProgress,
actionHandler: onAction
)
.frame(maxWidth: .infinity)
.bottomPanelStyle()
.background(Color.dash.secondaryBackground, ignoresSafeAreaEdges: .bottom)
}

}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,10 +157,6 @@ struct DashSpendUserAuthScreen: View {
viewModel.onContinue()
}
)
.frame(maxWidth: .infinity)
.frame(height: UIDevice.isIphone5OrLess ? 290 : 320)
.padding(.horizontal, 20)
.padding(.bottom, 20)
} else {
ZStack(alignment: .center) {
DashButton(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,22 +28,25 @@ enum HomeBalanceViewState: Int {
// MARK: - HomeBalanceView

/// Home header: the combined total (transparent + platform + shielded)
/// as the hero amount, then one row per balance with its fiat value and
/// circular in/out transfer buttons. Every row's in-arrow opens the
/// receive sheet (Receive ↔ Internal) with that balance pinned as the
/// destination; every out-arrow opens the mirrored send sheet
/// (Send ↔ Internal) with that balance pinned as the source.
/// as the hero amount, and — in Advanced mode only — one row per balance
/// with its fiat value.
///
/// The rows are a readout, not a control surface. They used to carry an
/// in/out arrow pair opening the pinned receive/send sheets; the transfer
/// routes those reached are all on the payments tab, and a second, denser
/// entry to them on the balance header was two taps the header did not need.
struct HomeBalanceView: View {
@ObservedObject var viewModel: BalanceModel
@ObservedObject private var platformSync = PlatformAddressSyncCoordinator.shared
@ObservedObject private var shieldedSync = ShieldedSyncMonitor.shared
@State private var opacity: Double = 0.3
var onLongPress: () -> Void
var onReceive: (ChainNetwork) -> Void = { _ in }
var onSend: (ChainNetwork) -> Void = { _ in }
/// Tap on a row's body (icon/title/amount — not the arrows): opens the
/// what-is-this-balance info sheet for that balance.
/// Tap on a row: opens the what-is-this-balance info sheet.
var onInfo: (ChainNetwork) -> Void = { _ in }
/// The breakdown is one of the three surfaces Advanced mode unlocks —
/// without it the wallet has one balance, and naming its parts invites
/// questions the simple mode is there to avoid.
var showsBreakdown: Bool = true

// Header nav-bar (SB-11) inputs, threaded in by HomeView from the same
// app-owned identity snapshot the UIKit nav-bar avatar reads. A nil
Expand Down Expand Up @@ -137,7 +140,7 @@ struct HomeBalanceView: View {
onLongPress()
}

if !viewModel.isBalanceHidden && platformSync.isRunning {
if showsBreakdown && !viewModel.isBalanceHidden && platformSync.isRunning {
breakdownCard
.padding(.horizontal, 16)
.padding(.top, 12)
Expand Down Expand Up @@ -205,26 +208,20 @@ struct HomeBalanceView: View {
icon: "wallet.pass",
title: NSLocalizedString("Transparent", comment: "Balance breakdown"),
duffs: viewModel.value,
infoAction: { onInfo(.core) },
inAction: { onReceive(.core) },
outAction: { onSend(.core) })
infoAction: { onInfo(.core) })
rowDivider
balanceRow(
icon: "cloud",
title: NSLocalizedString("Platform", comment: ""),
duffs: platformDuffs,
infoAction: { onInfo(.platform) },
inAction: { onReceive(.platform) },
outAction: { onSend(.platform) })
infoAction: { onInfo(.platform) })
rowDivider
balanceRow(
icon: "shield",
title: NSLocalizedString("Shielded", comment: ""),
duffs: shieldedDuffs,
isSyncing: shieldedSync.isSyncing || platformSync.isShieldedBalanceReconciling,
infoAction: { onInfo(.shielded) },
inAction: { onReceive(.shielded) },
outAction: { onSend(.shielded) })
infoAction: { onInfo(.shielded) })
}
.padding(.horizontal, 12)
.background(Color.dash.white.opacity(0.12))
Expand Down Expand Up @@ -254,13 +251,9 @@ struct HomeBalanceView: View {
title: String,
duffs: UInt64,
isSyncing: Bool = false,
infoAction: @escaping () -> Void,
inAction: @escaping () -> Void,
outAction: @escaping () -> Void
infoAction: @escaping () -> Void
) -> some View {
HStack(spacing: 8) {
// The row body is its own tap target (info sheet); keeping it a
// sibling of the arrow buttons means it can't swallow their taps.
HStack(spacing: 8) {
Image(systemName: icon)
.font(.system(size: 15))
Expand Down Expand Up @@ -293,32 +286,8 @@ struct HomeBalanceView: View {
}
.contentShape(Rectangle())
.onTapGesture { infoAction() }

transferButton(systemName: "arrow.down",
label: NSLocalizedString("Transfer in", comment: "Balance breakdown"),
action: inAction)
transferButton(systemName: "arrow.up",
label: NSLocalizedString("Transfer out", comment: "Balance breakdown"),
action: outAction)
}
.padding(.vertical, 9)
}

private func transferButton(
systemName: String,
label: String,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
Image(systemName: systemName)
.font(.system(size: 13, weight: .semibold))
.foregroundColor(Color.dash.whiteText)
.frame(width: 28, height: 28)
.background(Circle().fill(Color.dash.white.opacity(0.18)))
// Keep the visual small but the tap target comfortable.
.contentShape(Rectangle().inset(by: -8))
}
.buttonStyle(.plain)
.accessibilityLabel(label)
}
}
Loading
Loading