taler-ios

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

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 }