Skip to content

Commit 746aa76

Browse files
authored
fix: align withdraw minimum, gate, and summary math (#223)
* fix: align withdraw minimum, gate, and summary on shared rounding The amount-screen minimum and the summary's "Less fee"/"Net amount" used to compute through different rounding paths, producing user-visible math mismatches at certain rates (e.g. minimum displayed as ¥80 with summary fee shown as ¥78). Display, gate, and summary now derive from one half-up-rounded fee × rate, so the three numbers always match. The Next button below the fee is enabled and surfaces a "Withdrawal Amount Too Small" dialog instead of being silently disabled. Subtitle copy reads "Minimum withdrawal X" in red. Verified-state and submission paths are unchanged. * fix: parse entered amount with Decimal(string:) for locale safety NumberFormatter.decimal(from:) is locale-aware and parses "0.69" as 0 on non-"." separator locales (and through the .none-style fallback in the parse chain), so isBelowMinimumWithdraw fired even when the user entered exactly the displayed minimum. The keypad always emits "." regardless of device locale, matching what Decimal(string:) expects — and what EnterAmountView.isExceedingLimit already uses. * docs: pitfall — keypad-emitted amounts need Decimal(string:) Captures the locale-parsing trap that wasted hours on the withdraw minimum gate. NumberFormatter.decimal(from:) silently parses "0.69" as 0 on non-"." locales; Decimal(string:) is the established pattern for keypad input and matches EnterAmountView.isExceedingLimit.
1 parent 1195962 commit 746aa76

8 files changed

Lines changed: 266 additions & 148 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,7 @@ Types: `feat`, `fix`, `refactor`, `test`, `docs`, `chore`
519519
| `matchedGeometryEffect` applied after `.frame` | **`.matchedGeometryEffect` must come BEFORE `.frame` in the modifier chain.** Wrong order causes hero animations to fail silently: you see two separate views fading in/out at their own static positions instead of one morphing element. Paul Hudson's hackingwithswift example uses the wrong order and does not work on current iOS. Correct: `Rectangle().fill(.red).matchedGeometryEffect(id:in:).frame(width:height:)`. Incorrect: `Rectangle().fill(.red).frame(width:height:).matchedGeometryEffect(id:in:)`. Also note: `.transition(.identity)` on a parent containing matched views **kills the animation entirely** — matched geometry needs the parent view to remain in the tree briefly for interpolation, and `.identity` removes it instantly. |
520520
| Binding the same `dialogItem` to `.dialog(item:)` on two views in the live hierarchy | `dialog(item:)` is `.sheet(item:)` under the hood (`Dialog+View.swift`). When two views in the live tree bind the same observable — e.g. `ScanScreen` *and* a sheet `ScanScreen` is currently presenting — both attempt to present the dialog, and UIKit logs `Currently, only presenting a single sheet is supported`. For dialogs that need to fire across sheet boundaries (a viewmodel referenced by both `ScanScreen` and a router-presented sheet, or an error that fires *while* a sheet is being torn down), route through `session.dialogItem`. `DialogWindow` hosts that binding in a separate `UIWindow` at `UIWindow.Level.alert` and renders above every sheet without joining the main window's presentation queue. Per-screen state that's only ever bound by one view in the tree (e.g. a `@State DialogItem?` on a leaf) is fine to keep local. |
521521
| Calling `router.present(.x)` next to a viewmodel mutator that may *block* the flow | A viewmodel that surfaces a blocking error via `session.dialogItem` (e.g. `GiveViewModel.showNoBalanceError`) does not stop the router — `DialogWindow` renders the dialog above the sheet, but the sheet is still presented underneath and reappears once the dialog is dismissed. Gate the router on the precondition: expose `attemptPresent() -> Bool` on the viewmodel and write `if vm.attemptPresent() { router.present(.x) }`. Putting the check inside an `isPresented` `didSet` is not enough — the router call still runs unconditionally on the next line. |
522+
| Parsing keypad-emitted amounts with `NumberFormatter.decimal(from:)` | `KeyPadView` always emits "." as the decimal separator regardless of device locale. `NumberFormatter.decimal(from:)` is locale-aware and falls through `genericDecimal` and `generic` (style `.none`) — on non-"." locales those parse "0.69" as 0, silently breaking fee gates and limit comparisons (the user types the displayed minimum and the gate fires anyway). **Use `Decimal(string:)` for any string the keypad produced** — it always treats "." as the decimal separator and matches the established pattern in `EnterAmountView.isExceedingLimit`. `NumberFormatter.decimal(from:)` is appropriate only when parsing currency-formatted strings (already through the formatter, locale-correct). |
522523

523524
---
524525

Flipcash/Core/Screens/Settings/Withdraw/WithdrawScreen.swift

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -88,15 +88,9 @@ struct WithdrawScreen: View {
8888
}
8989
}
9090
.onAppear {
91-
// Wire the view model's navigation callbacks. Push substeps onto
92-
// the parent (Settings) NavigationStack via the router; pops
93-
// remove that many items from the top.
9491
viewModel.pushSubstep = { step in
9592
router.pushAny(step)
9693
}
97-
viewModel.popSubsteps = { count in
98-
router.popLast(count, on: .settings)
99-
}
10094
viewModel.onComplete = {
10195
// Successful withdrawal: unwind the entire flow back to
10296
// Settings root. Using `dismiss()` here would tear down the

Flipcash/Core/Screens/Settings/Withdraw/WithdrawSummaryScreen.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ struct WithdrawSummaryScreen: View {
1515
Background(color: .backgroundMain) {
1616
VStack(spacing: 20) {
1717
if let entered = viewModel.enteredFiat,
18-
let net = viewModel.withdrawableAmount,
18+
let net = viewModel.displayNet,
1919
let display = viewModel.youReceiveDisplayValue {
2020
BorderedContainer {
2121
VStack(spacing: 20) {
@@ -30,7 +30,7 @@ struct WithdrawSummaryScreen: View {
3030
)
3131
SummaryLineItem(
3232
title: "Net amount",
33-
value: net.nativeAmount.formatted()
33+
value: net.formatted()
3434
)
3535
if let amountText = viewModel.amountInTokenText,
3636
let kind = viewModel.kind {

Flipcash/Core/Screens/Settings/Withdraw/WithdrawViewModel.swift

Lines changed: 65 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,10 @@ import FlipcashUI
1111

1212
@MainActor @Observable
1313
class WithdrawViewModel {
14-
/// Tracks which sub-step screens have been pushed. Mirrors the
15-
/// `WithdrawNavigationPath` items the model has appended to the parent
16-
/// navigation stack via the `pushSubstep` callback. Used by
17-
/// `popToEnterAmount` to compute how many items to pop.
18-
@ObservationIgnored private var substepStack: [WithdrawNavigationPath] = []
19-
2014
/// Pushes a sub-step onto the parent NavigationStack. Wired by
2115
/// `WithdrawScreen` to call `router.pushAny(_:on: .settings)`.
2216
@ObservationIgnored var pushSubstep: (WithdrawNavigationPath) -> Void = { _ in }
2317

24-
/// Pops the given number of items from the parent NavigationStack.
25-
/// Wired by `WithdrawScreen` to call `router.popLast(_:on: .settings)`.
26-
@ObservationIgnored var popSubsteps: (Int) -> Void = { _ in }
27-
2818
var withdrawButtonState: ButtonState = .normal
2919
var kind: WithdrawKind?
3020

@@ -47,26 +37,47 @@ class WithdrawViewModel {
4737
computeAmount(using: ratesController.rateForEntryCurrency(), pinnedSupplyQuarks: nil)
4838
}
4939

40+
/// Fee in entry currency for the summary's "Less fee" line. Rounded
41+
/// half-up to currency precision so the implied fee on the amount screen
42+
/// (`minimumWithdrawAmount = displayFee + smallestUnit`) matches the
43+
/// summary verbatim — no rounding drift between the two screens.
5044
var displayFee: FiatAmount? {
51-
guard let enteredFiat, let withdrawableAmount else {
52-
return nil
53-
}
54-
let entered = enteredFiat.nativeAmount
55-
let withdrawable = withdrawableAmount.nativeAmount
56-
guard entered.currency == withdrawable.currency, entered >= withdrawable else {
57-
return nil
58-
}
59-
return entered - withdrawable
45+
guard let enteredFiat, let fee = resolvedFee else { return nil }
46+
let feeInEntry = fee.usd.converting(to: enteredFiat.currencyRate)
47+
let rounded = feeInEntry.value.rounded(to: feeInEntry.currency.maximumFractionDigits)
48+
return FiatAmount(value: rounded, currency: feeInEntry.currency)
6049
}
6150

62-
/// Returns the amount by which the fee exceeds the entered amount, or nil if the fee is covered.
63-
/// Used by `completeWithdrawalAction` to block withdrawals where the fee exceeds the amount,
64-
/// and by the summary screen to display the negative delta.
65-
var negativeWithdrawableAmount: FiatAmount? {
51+
/// Net in entry currency, derived as `entered − displayFee` so the three
52+
/// summary lines (Withdrawal amount, Less fee, Net amount) form a closed
53+
/// identity. Falls back to `entered` when there's no fee.
54+
var displayNet: FiatAmount? {
6655
guard let enteredFiat else { return nil }
67-
guard let fee = resolvedFee else { return nil }
68-
guard fee.onChain >= enteredFiat.onChainAmount else { return nil }
69-
return (fee.usd - enteredFiat.usdfValue).converting(to: enteredFiat.currencyRate)
56+
let entered = enteredFiat.nativeAmount
57+
guard let fee = displayFee else { return entered }
58+
return entered - fee
59+
}
60+
61+
/// Smallest amount that yields at least one displayable unit of net after
62+
/// the fee. `displayFee + smallest_displayable_unit` in the entry currency.
63+
/// Display and the `isBelowMinimumWithdraw` gate use the same number by
64+
/// construction.
65+
var minimumWithdrawAmount: FiatAmount? {
66+
guard let displayFee else { return nil }
67+
let precision = displayFee.currency.maximumFractionDigits
68+
let smallestUnit = Decimal(sign: .plus, exponent: -precision, significand: 1)
69+
return FiatAmount(value: displayFee.value + smallestUnit, currency: displayFee.currency)
70+
}
71+
72+
/// Compares the raw entered Decimal against `minimumWithdrawAmount`.
73+
/// Uses `Decimal(string:)` (always "." separator, matching the keypad's
74+
/// output and `EnterAmountView.isExceedingLimit`) instead of the
75+
/// locale-aware `NumberFormatter.decimal(from:)`, which on non-"."
76+
/// locales parses "0.69" as 0 and falsely fires the gate.
77+
var isBelowMinimumWithdraw: Bool {
78+
guard let minimum = minimumWithdrawAmount else { return false }
79+
guard let entered = Decimal(string: enteredAmount) else { return false }
80+
return entered < minimum.value
7081
}
7182

7283
var withdrawableAmount: ExchangedFiat? {
@@ -99,10 +110,10 @@ class WithdrawViewModel {
99110

100111
/// Gate for the Enter-Amount screen's Next button. Disables when the
101112
/// entered amount exceeds the displayed balance cap (so `EnterAmountView`
102-
/// turns the subtitle red) or fails to cover the withdrawal fee.
113+
/// turns the subtitle red). Below-fee entries keep the button enabled so
114+
/// `amountEnteredAction` can surface the dialog explaining the floor.
103115
var canProceedToAddress: Bool {
104116
guard enteredFiat != nil else { return false }
105-
guard negativeWithdrawableAmount == nil else { return false }
106117
return EnterAmountCalculator.isWithinDisplayLimit(
107118
enteredAmount: enteredAmount,
108119
max: maxWithdrawLimit.nativeAmount
@@ -153,15 +164,15 @@ class WithdrawViewModel {
153164
}
154165

155166
/// Single string rendered inside the "You Receive" box on the summary.
156-
/// USDF: native fiat amount (e.g. "$49.50"). Bonded: scaled token quantity
157-
/// (matches `amountInTokenText` — same number, different framing on screen).
167+
/// USDF: net fiat (matches the summary's `Net amount` line). Bonded:
168+
/// scaled token quantity (matches `amountInTokenText` — same number,
169+
/// different framing on screen).
158170
var youReceiveDisplayValue: String? {
159-
guard let withdrawableAmount else { return nil }
160171
switch kind {
161172
case .sameMint:
162-
return withdrawableAmount.onChainAmount.decimalValue.formatted()
173+
return withdrawableAmount?.onChainAmount.decimalValue.formatted()
163174
case .usdfToUsdc:
164-
return withdrawableAmount.nativeAmount.formatted()
175+
return displayNet?.formatted()
165176
case .none:
166177
return nil
167178
}
@@ -181,15 +192,13 @@ class WithdrawViewModel {
181192
}
182193
}
183194

184-
/// Subtitle for the amount-entry screen. "Enter more than $X.XX" when the
185-
/// entered amount is at or below the fee (a hard gate that disables Next).
195+
/// Subtitle for the amount-entry screen. "Minimum withdrawal $X.XX" when
196+
/// the entered amount is at or below the fee, hinting at the floor without
197+
/// disabling Next — the gating dialog fires from `amountEnteredAction`.
186198
/// Otherwise the "Enter up to $Y.YY" balance-with-limit copy.
187199
var amountSubtitle: EnterAmountView.Subtitle {
188-
if let enteredFiat,
189-
negativeWithdrawableAmount != nil,
190-
let fee = resolvedFee {
191-
let feeInEntryCurrency = fee.usd.converting(to: enteredFiat.currencyRate)
192-
return .custom("Enter more than \(feeInEntryCurrency.formatted())")
200+
if isBelowMinimumWithdraw, let minimum = minimumWithdrawAmount {
201+
return .error("Minimum withdrawal \(minimum.formatted())")
193202
}
194203
return .balanceWithLimit(maxWithdrawLimit)
195204
}
@@ -350,6 +359,11 @@ class WithdrawViewModel {
350359
}
351360

352361
func amountEnteredAction() {
362+
if isBelowMinimumWithdraw {
363+
showWithdrawalTooSmallError()
364+
return
365+
}
366+
353367
guard let exchangedFiat = enteredFiat else {
354368
return
355369
}
@@ -371,21 +385,6 @@ class WithdrawViewModel {
371385
}
372386

373387
func completeWithdrawalAction() {
374-
guard negativeWithdrawableAmount == nil else {
375-
dialogItem = .init(
376-
style: .destructive,
377-
title: "Withdrawal Amount Too Small",
378-
subtitle: "Your withdrawal amount is too small to cover the fee. Please try a different amount",
379-
dismissable: true
380-
) {
381-
.okay(kind: .standard) { [weak self] in
382-
self?.resetEnteredAmount()
383-
self?.popToEnterAmount()
384-
}
385-
}
386-
return
387-
}
388-
389388
dialogItem = .init(
390389
style: .destructive,
391390
title: "Are You Sure?",
@@ -490,44 +489,22 @@ class WithdrawViewModel {
490489
enteredAddress = address.base58
491490
}
492491

493-
// MARK: - Reset -
494-
495-
private func resetEnteredAmount() {
496-
enteredAmount = ""
497-
}
498-
499492
// MARK: - Navigation -
500493

501-
private func popToEnterAmount() {
502-
// Pop everything above `.enterAmount`, leaving it as the top substep.
503-
// If we're already there or the stack is empty, this is a no-op.
504-
guard let firstAmountIndex = substepStack.firstIndex(of: .enterAmount) else {
505-
return
506-
}
507-
let popsNeeded = substepStack.count - (firstAmountIndex + 1)
508-
guard popsNeeded > 0 else { return }
509-
popSubsteps(popsNeeded)
510-
substepStack.removeLast(popsNeeded)
511-
}
512-
513494
private func pushIntroScreen() {
514495
pushSubstep(.intro)
515-
substepStack.append(.intro)
516496
}
517497

518498
func pushEnterAmountScreen() {
519499
pushSubstep(.enterAmount)
520-
substepStack.append(.enterAmount)
521500
}
522501

523502
private func pushEnterAddressScreen() {
524503
pushSubstep(.enterAddress)
525-
substepStack.append(.enterAddress)
526504
}
527505

528506
private func pushConfirmationScreen() {
529507
pushSubstep(.confirmation)
530-
substepStack.append(.confirmation)
531508
}
532509

533510
// MARK: - Dialogs -
@@ -555,6 +532,17 @@ class WithdrawViewModel {
555532
.okay(kind: .destructive)
556533
}
557534
}
535+
536+
private func showWithdrawalTooSmallError() {
537+
dialogItem = .init(
538+
style: .destructive,
539+
title: "Withdrawal Amount Too Small",
540+
subtitle: "Your withdrawal amount is too small to cover the fee. Please try a different amount",
541+
dismissable: true
542+
) {
543+
.okay(kind: .standard)
544+
}
545+
}
558546
}
559547

560548
enum WithdrawNavigationPath {

Flipcash/UI/EnterAmountView.swift

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,10 +103,10 @@ public struct EnterAmountView: View {
103103
.foregroundColor(subtitleColor)
104104
.font(.appTextMedium)
105105

106-
case .custom(let text):
106+
case .error(let text):
107107
Text(text)
108108
.fixedSize()
109-
.foregroundColor(subtitleColor)
109+
.foregroundColor(.textError)
110110
.font(.appTextMedium)
111111
}
112112
}
@@ -198,7 +198,9 @@ extension EnterAmountView {
198198
enum Subtitle {
199199
case singleTransactionLimit
200200
case balanceWithLimit(ExchangedFiat)
201-
case custom(String)
201+
/// Always rendered in `textError`. Use for soft-validation copy where
202+
/// Next stays enabled and the caller surfaces a dialog on tap.
203+
case error(String)
202204
}
203205
}
204206

0 commit comments

Comments
 (0)