taler-ios

iOS apps for GNU Taler (wallet)
Log | Files | Refs | README | LICENSE

commit 282666f0866775b3559a07ab8e96d847dccd85ff
parent b303592f6c1c36efa37929ac6382c9bc043d9331
Author: Marc Stibane <marc@taler.net>
Date:   Fri, 14 Aug 2026 18:02:13 +0200

AI: clamp (against deleted exchange)

Diffstat:
MTalerWallet1/Views/HelperViews/ScopePicker.swift | 60++++++++++++++++++++++++++++++++++++++----------------------
1 file changed, 38 insertions(+), 22 deletions(-)

diff --git a/TalerWallet1/Views/HelperViews/ScopePicker.swift b/TalerWallet1/Views/HelperViews/ScopePicker.swift @@ -17,6 +17,14 @@ fileprivate func urlOrCurrency(_ balance: Balance) -> String { balance.scopeInfo.url?.trimURL ?? balance.scopeInfo.currency } +/// The selected index is `@State´ resp. a `@Binding´, and thus can outlive the balances +/// array it indexes into - e.g. when an exchange was deleted while this view is on the +/// navigation stack. Never subscript `controller.balances´ unchecked. +fileprivate func safeBalance(_ balances: [Balance], _ index: Int) -> Balance? { + balances.indices.contains(index) ? balances[index] + : balances.first +} + fileprivate func pickerRow(_ balance: Balance, _ currencyInfo: CurrencyInfo) -> (String, String) { let formatted = formattedAmount(balance, currencyInfo) let urlOrCurrency = urlOrCurrency(balance) @@ -70,8 +78,7 @@ struct ScopePicker: View { let _ = Self._printChanges() #endif let count = controller.balances.count - if (count > 0) { - let balance = controller.balances[selected] + if let balance = safeBalance(controller.balances, selected) { let currencyInfo = controller.info(for: balance.scopeInfo, controller.currencyTicker) let available = balance.available let availableA11y = available.formatted(currencyInfo, isNegative: false, @@ -115,7 +122,14 @@ struct ScopePicker: View { .accessibilityHint(disabled ? EMPTYSTRING : chooseHint) .task() { // FIXME: check balance for non-zero and disable-peer-payments - withAnimation { selected = value } + // never hand an out-of-range index to `action´ - the callers subscript + // `controller.balances´ with it + withAnimation { selected = min(max(value, 0), count - 1) } + } + .onChange(of: count) { newCount in + if selected >= newCount { // balances shrank, e.g. exchange deleted + selected = 0 + } } .onChange(of: selected) { newValue in action(newValue) @@ -183,7 +197,8 @@ struct ScopeDropDown: View { let chevron = Image(systemName: CHEVRON_UP) // 􀆇 let foreColor = disabled ? Color.secondary : Color.primary let backColor = disabled ? WalletColors().backgroundColor : WalletColors().gray4 - let otherBalances = controller.balances.filter { $0 != controller.balances[selection] } + let selectedBalance = safeBalance(controller.balances, selection) + let otherBalances = controller.balances.filter { $0 != selectedBalance } let theList = LazyVStack(spacing: 0) { ForEach(0..<otherBalances.count, id: \.self) { index in let balance = otherBalances[index] @@ -211,28 +226,29 @@ struct ScopeDropDown: View { } VStack { // selected item - let balance = controller.balances[selection] - let currencyInfo = controller.info(for: balance.scopeInfo, controller.currencyTicker) - let noAmount = !showDropdown - Group { - if disabled { - dropDownRow(balance, currencyInfo, noAmount) - } else { - Button(action: buttonAction) { - HStack(alignment: .firstTextBaseline) { - dropDownRow(balance, currencyInfo, noAmount) - .accessibilityAddTraits(.isSelected) - Spacer() - chevron.rotationEffect(.degrees((showDropdown ? -180 : 0))) - .accessibilityHidden(true) + if let balance = selectedBalance { + let currencyInfo = controller.info(for: balance.scopeInfo, controller.currencyTicker) + let noAmount = !showDropdown + Group { + if disabled { + dropDownRow(balance, currencyInfo, noAmount) + } else { + Button(action: buttonAction) { + HStack(alignment: .firstTextBaseline) { + dropDownRow(balance, currencyInfo, noAmount) + .accessibilityAddTraits(.isSelected) + Spacer() + chevron.rotationEffect(.degrees((showDropdown ? -180 : 0))) + .accessibilityHidden(true) + } } } } + .padding(.vertical, 4) + .padding(.horizontal, radius / 2) + .frame(maxWidth: .infinity, alignment: .leading) +// .border(.red) } - .padding(.vertical, 4) - .padding(.horizontal, radius / 2) - .frame(maxWidth: .infinity, alignment: .leading) -// .border(.red) if (showDropdown) { if #available(iOS 17.0, *) { let count = controller.balances.count