ScopePicker.swift (11637B)
1 /* 2 * This file is part of GNU Taler, ©2022-26 Taler Systems S.A. 3 * See LICENSE.md 4 */ 5 /** 6 * @author Marc Stibane 7 */ 8 import SwiftUI 9 import taler_swift 10 11 fileprivate func formattedAmount(_ balance: Balance, _ currencyInfo: CurrencyInfo) -> (String, String) { 12 let amount = balance.available 13 return amount.formatted(currencyInfo, isNegative: false, useISO: false) 14 } 15 16 fileprivate func urlOrCurrency(_ balance: Balance) -> String { 17 balance.scopeInfo.url?.trimURL ?? balance.scopeInfo.currency 18 } 19 20 /// The selected index is `@State´ resp. a `@Binding´, and thus can outlive the balances 21 /// array it indexes into - e.g. when an exchange was deleted while this view is on the 22 /// navigation stack. Never subscript `controller.balances´ unchecked. 23 fileprivate func safeBalance(_ balances: [Balance], _ index: Int) -> Balance? { 24 balances.indices.contains(index) ? balances[index] 25 : balances.first 26 } 27 28 fileprivate func pickerRow(_ balance: Balance, _ currencyInfo: CurrencyInfo) -> (String, String) { 29 let formatted = formattedAmount(balance, currencyInfo) 30 let urlOrCurrency = urlOrCurrency(balance) 31 return (String("\(urlOrCurrency):\t\(formatted.0.nbs)"), 32 String("\(urlOrCurrency): \(formatted.1)")) 33 } 34 35 struct CurrencyPicker: View { 36 let stack: CallStack 37 @Binding var value: Int 38 let exchanges: [DefaultExchange] 39 let action: (Int) -> Void 40 41 @State private var selected = 0 42 43 var body: some View { 44 Picker(EMPTYSTRING, selection: $selected) { 45 ForEachWithIndex(array: exchanges) { index, exchange in 46 if exchange.talerUri.contains("exchange.stage.taler-ops.ch") { 47 Text(exchange.currencySpec.name + " (stage)") 48 .tag(index) 49 } else { 50 Text(exchange.currencySpec.name) 51 .tag(index) 52 } 53 } 54 } 55 .talerFont(.title3) 56 .task() { 57 // FIXME: check balance for non-zero and disable-peer-payments 58 withAnimation { selected = value } 59 } 60 .onChange(of: selected) { newValue in 61 action(newValue) 62 } 63 } 64 } 65 struct ScopePicker: View { 66 // private let symLog = SymLogV(0) 67 let stack: CallStack 68 @Binding var value: Int 69 let onlyNonZero: Bool 70 let action: (Int) -> Void 71 72 @EnvironmentObject private var controller: Controller 73 74 @State private var selected = 0 75 76 var body: some View { 77 #if PRINT_CHANGES 78 let _ = Self._printChanges() 79 #endif 80 let count = controller.balances.count 81 if let balance = safeBalance(controller.balances, selected) { 82 let currencyInfo = controller.info(for: balance.scopeInfo, controller.currencyTicker) 83 let available = balance.available 84 let availableA11y = available.formatted(currencyInfo, isNegative: false, 85 useISO: true, a11yDecSep: ".") 86 let url = balance.scopeInfo.url?.trimURL ?? EMPTYSTRING 87 // let a11yLabel = url + ", " + availableA11y 88 let chooseHint = String(localized: "Choose the payment service:", comment: "a11y") 89 let disabled = (count == 1) 90 91 HStack(alignment: .firstTextBaseline) { 92 Text("via", comment: "ScopePicker") 93 .accessibilityHidden(true) 94 .foregroundColor(disabled ? .secondary : .primary) 95 96 if #available(iOS 16.4, *) { 97 ScopeDropDown(selection: $selected, 98 onlyNonZero: onlyNonZero, 99 disabled: disabled) 100 } else { 101 Picker(EMPTYSTRING, selection: $selected) { 102 ForEach(0..<count, id: \.self) { index in 103 let balance = controller.balances[index] 104 let currencyInfo = controller.info(for: balance.scopeInfo, controller.currencyTicker) 105 let pickerRow = pickerRow(balance, currencyInfo) 106 Text(pickerRow.0) 107 .accessibilityLabel(pickerRow.1) 108 .tag(index) 109 // .selectionDisabled(balance.available.isZero) needs iOS 17 110 } 111 } 112 .disabled(disabled) 113 .frame(maxWidth: .infinity) 114 // .border(.red) // debugging 115 .pickerStyle(.menu) 116 // .accessibilityLabel(a11yLabel) 117 .labelsHidden() // be sure to use valid labels for a11y 118 } 119 } 120 .talerFont(.picker) 121 // .accessibilityLabel(a11yLabel) 122 .accessibilityHint(disabled ? EMPTYSTRING : chooseHint) 123 .task() { 124 // FIXME: check balance for non-zero and disable-peer-payments 125 // never hand an out-of-range index to `action´ - the callers subscript 126 // `controller.balances´ with it 127 withAnimation { selected = min(max(value, 0), count - 1) } 128 } 129 .onChange(of: count) { newCount in 130 if selected >= newCount { // balances shrank, e.g. exchange deleted 131 selected = 0 132 } 133 } 134 .onChange(of: selected) { newValue in 135 action(newValue) 136 } 137 } 138 } 139 } 140 // MARK: - 141 @available(iOS 16.4, *) 142 struct ScopeDropDown: View { 143 @Binding var selection: Int 144 let onlyNonZero: Bool 145 let disabled: Bool 146 147 // var maxItemDisplayed: Int = 3 148 149 @EnvironmentObject private var controller: Controller 150 151 @State private var scrollPosition: Int? 152 @State private var showDropdown = false 153 154 func buttonAction() { 155 withAnimation { 156 showDropdown.toggle() 157 } 158 } 159 160 @ViewBuilder 161 func dropDownRow(_ balance: Balance, _ currencyInfo: CurrencyInfo, _ first: Bool = false) -> some View { 162 let urlOrCurrency = urlOrCurrency(balance) 163 let text = Text(urlOrCurrency) 164 let formatted = formattedAmount(balance, currencyInfo) 165 let amount = Text(formatted.0.nbs) 166 if first { 167 let a11yLabel = String(localized: "via \(urlOrCurrency)", comment: "a11y") 168 text 169 .accessibilityLabel(a11yLabel) 170 } else { 171 let a11yLabel = "\(urlOrCurrency), \(formatted.1)" 172 let hLayout = HStack(alignment: .firstTextBaseline) { 173 text 174 Spacer() 175 amount 176 }.padding(.vertical, 4) 177 let vLayout = VStack(alignment: .leading) { 178 text 179 HStack { 180 Spacer() 181 amount 182 Spacer() 183 } 184 } 185 ViewThatFits(in: .horizontal) { 186 hLayout 187 vLayout 188 } 189 .accessibilityElement(children: .combine) 190 .accessibilityLabel(a11yLabel) 191 } 192 } 193 194 var body: some View { 195 let radius = 8.0 196 VStack { 197 let chevron = Image(systemName: CHEVRON_UP) // 198 let foreColor = disabled ? Color.secondary : Color.primary 199 let backColor = disabled ? WalletColors().backgroundColor : WalletColors().gray4 200 let selectedBalance = safeBalance(controller.balances, selection) 201 let otherBalances = controller.balances.filter { $0 != selectedBalance } 202 let theList = LazyVStack(spacing: 0) { 203 ForEach(0..<otherBalances.count, id: \.self) { index in 204 let balance = otherBalances[index] 205 let disablePeerPayments = balance.disablePeerPayments ?? false 206 let rowDisabled = disablePeerPayments || (onlyNonZero ? balance.available.isZero : false) 207 Button(action: { 208 withAnimation { 209 showDropdown.toggle() 210 selection = controller.balances.firstIndex(of: balance) ?? selection 211 } 212 }, label: { 213 let currencyInfo = controller.info(for: balance.scopeInfo, controller.currencyTicker) 214 HStack(alignment: .top) { 215 dropDownRow(balance, currencyInfo) // .border(.random) 216 .foregroundColor(rowDisabled ? .secondary : .primary) 217 Spacer() 218 chevron.foregroundColor(.clear) 219 .accessibilityHidden(true) 220 } 221 }) 222 .disabled(rowDisabled) 223 .padding(.horizontal, radius / 2) 224 .frame(maxWidth: .infinity, alignment: .leading) 225 } 226 } 227 VStack { 228 // selected item 229 if let balance = selectedBalance { 230 let currencyInfo = controller.info(for: balance.scopeInfo, controller.currencyTicker) 231 let noAmount = !showDropdown 232 Group { 233 if disabled { 234 dropDownRow(balance, currencyInfo, noAmount) 235 } else { 236 Button(action: buttonAction) { 237 HStack(alignment: .firstTextBaseline) { 238 dropDownRow(balance, currencyInfo, noAmount) 239 .accessibilityAddTraits(.isSelected) 240 Spacer() 241 chevron.rotationEffect(.degrees((showDropdown ? -180 : 0))) 242 .accessibilityHidden(true) 243 } 244 } 245 } 246 } 247 .padding(.vertical, 4) 248 .padding(.horizontal, radius / 2) 249 .frame(maxWidth: .infinity, alignment: .leading) 250 // .border(.red) 251 } 252 if (showDropdown) { 253 if #available(iOS 17.0, *) { 254 let count = controller.balances.count 255 // let toomany = count > maxItemDisplayed 256 // let scrollViewHeight = buttonHeight * CGFloat(toomany ? maxItemDisplayed 257 // : count) 258 ScrollView { 259 theList 260 .scrollTargetLayout() 261 } 262 // .border(.red) 263 .scrollPosition(id: $scrollPosition) 264 .scrollDisabled(count <= 3) 265 // .frame(height: scrollViewHeight) 266 .onAppear { 267 scrollPosition = selection 268 } 269 } else { 270 // Fallback on earlier versions 271 ScrollView { 272 theList 273 } 274 } 275 276 } 277 } 278 .foregroundStyle(foreColor) 279 .background(RoundedRectangle(cornerRadius: radius).fill(backColor)) 280 } 281 .frame(maxWidth: .infinity, alignment: .top) 282 .zIndex(100) 283 } 284 }