View Issue Details

IDProjectCategoryView StatusLast Update
0011979Talerwallet (iOS App)public2026-11-09 09:20
Reporterbot-cg [agent] Assigned ToResolution deadline
ImplementerReviewer 
Release signed byDeployed by 
VerifierCommunicatorCM_7  
Data protection authority notification requiredNo  
CSIRT notification requiredNo  
Estimated effort (hours)Actual effort (hours)0 
Fast-trackNo
Work packagenone
PrioritynormalSeverityminorReproducibilityalways
Status newResolutionopen 
Summary0011979: Send / Deposit amount: an amount over the balance is signalled only by turning a heading red -- no message, nothing for VoiceOver, and the disabled button's hint gives the wrong reason (patch attached)
DescriptionType more than the wallet holds into Send (or Deposit) and the "Next"
button goes dead. The only other thing that changes on screen is that the
"Available for transfer: X" heading turns red. No message appears, and the
app already owns the right words elsewhere.

What the user gets
------------------
* **Sighted**: colour, and nothing else. `CurrencyInputView.swift:160-168`:

      .foregroundColor(insufficient ? WalletColors().errorColor : .primary)

  That is the whole message. Colour as the sole carrier of meaning is what
  WCAG 1.4.1 is about, and red-on-grey is the first thing lost to a colour
  vision deficiency or a dimmed screen.

* **VoiceOver**: nothing, and then a wrong reason. The same `Text` carries
  `.accessibilityLabel(heading.1)`, which is the *unchanged* "Available for
  transfer: X" -- the colour is not announced, so the only signal a sighted
  user gets is the one signal a blind user does not. Then the disabled
  button's hint (`AmountInputV.swift:108`) says

      "enabled when amount is non-zero"

  for all three disabling reasons -- `insufficient || isZero || invalid ||
  coinData.tooMany` (`:91`). With an amount of 999 and a balance of 25 the
  amount is plainly not zero, so the hint is not merely unhelpful, it is
  false.

The app has the right strings already
-------------------------------------
`InsufficientBalanceHint.localizedCause` (`WalletModel.swift:13-60`) has
eight translated causes, including "Balance insufficient! You don't have
enough \(currency)." and "Purchase not possible due to age restriction",
and the payment sheet prints them (`PaymentScan.swift:207-211`). None of
them reaches the Send/Deposit amount screen.

And the omission is specific, not a policy: the *same* screen explains its
other disabling reason in words. Too many coins prints "Amount too big for
a single withdrawal!" (`QuiteSomeCoins.swift:91`). Insufficiency, the far
more common case, prints nothing.

It also matters that the verdict is wallet-core's, not arithmetic.
`checkAvailable` takes `coreInsufficient` first and says why in a comment
(`AmountInputV.swift:70-77`): fees, age restrictions, per-exchange limits
and pending refreshes. So the heading can turn red while the typed amount
is visibly *below* the balance shown right above it -- and nothing on screen
accounts for the difference.

Reproducing
-----------
Fund a wallet with KUDOS:25, Actions -> Send, type 999. Next is disabled,
the heading is red, no text. As a deterministic XCUITest scenario:
`scenarios/candidate/send-insufficient-silent.json`, which asserts the
absence on purpose and has to be rewritten when this is fixed.

Patch
-----
Attached (`ios-insufficient-in-words.diff`): `CurrencyInputView` prints the
existing `payment_balance_insufficient_max` string under the heading when
`insufficient`, and the disabled button's hint names the actual reason.
Builds clean (`BUILD SUCCEEDED`, iphonesimulator, Xcode 26).

The patch deliberately uses the plainest of the eight causes. Carrying the
specific `InsufficientBalanceHint` through from wallet-core's verdict --
so the age-restriction and pending-refresh cases say what they are -- would
be the better fix and needs a signature change in `ComputeFeeResult`, which
is a decision for whoever owns that type.

Related, not duplicates: 11858 (no error message when no denominations are
available -- different trigger, same family), 11959 (the subject screen's
VoiceOver hint names a condition the button is not gated on -- the same
hint-vs-gate mismatch, one screen earlier).
TagsNo tags attached.
Attached Files
ios-insufficient-in-words.diff (3,422 bytes)   
diff --git a/TalerCommon/HelperViews/CurrencyInputView.swift b/TalerCommon/HelperViews/CurrencyInputView.swift
index c3c5697b..485eb622 100644
--- a/TalerCommon/HelperViews/CurrencyInputView.swift
+++ b/TalerCommon/HelperViews/CurrencyInputView.swift
@@ -129,6 +129,13 @@ struct CurrencyInputView: View {
         availableString(available?.readableDescription ?? String(localized: "unknown"))
     }
 
+    /// Why the amount cannot be sent.  Deliberately the same string the
+    /// payment sheet uses for `wallet-balance-available-insufficient`.
+    var insufficientHint: String {
+        String(localized: "payment_balance_insufficient_max",
+            defaultValue: "Balance insufficient! You don't have enough \(amount.currencyStr).")
+    }
+
     func heading() -> (String, String)? {
         if let title {
             return (title, a11yTitle)
@@ -166,6 +173,20 @@ struct CurrencyInputView: View {
                     .accessibilityLabel(heading.1)
                     .padding(.bottom, -6)
             }
+            if insufficient {
+                // Turning the heading red was the whole message: no words for
+                // a sighted user, and nothing at all for VoiceOver, which
+                // reads the unchanged `heading.1`.  The payment sheet already
+                // says this in words (PaymentScan.swift:207-211 via
+                // InsufficientBalanceHint.localizedCause), so reuse that
+                // string rather than invent a second one -- it is already
+                // translated.
+                Text(insufficientHint)
+                    .padding(.horizontal, 4)
+                    .frame(maxWidth: .infinity, alignment: title != nil ? .leading : .trailing)
+                    .talerFont(.subheadline)
+                    .foregroundColor(WalletColors().errorColor)
+            }
             currencyField
                 .frame(maxWidth: .infinity, alignment: .trailing)
                 .foregroundColor(WalletColors().fieldForeground)     // text color
diff --git a/TalerWallet1/Views/HelperViews/AmountInputV.swift b/TalerWallet1/Views/HelperViews/AmountInputV.swift
index 87482845..268c9c7f 100644
--- a/TalerWallet1/Views/HelperViews/AmountInputV.swift
+++ b/TalerWallet1/Views/HelperViews/AmountInputV.swift
@@ -106,7 +106,15 @@ struct AmountInputV: View {
         let currency = amountToTransfer.currencyStr
         let coinData = CoinData(coins: numCoins, fee: feeAmount)
         let flags = checkAvailable(coinData)
-        let hint = String(localized: "enabled when amount is non-zero", comment: "a11y")
+        // One hint for three different reasons was wrong twice over: a
+        // VoiceOver user with too high an amount was told the button needs a
+        // non-zero one, and the real reason -- not enough money, or too many
+        // coins -- was never spoken at all.
+        let hint = flags.insufficient
+                 ? String(localized: "enabled when the amount fits the available balance", comment: "a11y")
+                 : coinData.tooMany
+                 ? String(localized: "enabled when the amount is small enough for a single transfer", comment: "a11y")
+                 : String(localized: "enabled when amount is non-zero", comment: "a11y")
         ScrollView { VStack(alignment: .trailing) {
             CurrencyInputView(scope: scope,
                              amount: $amountToTransfer,
ios-insufficient-in-words.diff (3,422 bytes)   

Required notifications

Address or linkRemarkAdded byNotifiedActions

Activities

Issue History

Date ModifiedUsernameFieldChange
2026-11-09 09:20bot-cgNew Issue
2026-11-09 09:20bot-cgFile Added: ios-insufficient-in-words.diff