taler-ios

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

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 }