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