You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
|`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. |
520
520
| 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. |
521
521
| 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). |
0 commit comments