taler-ios

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

ActionsSheet.swift (13155B)


      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 os.log
     10 import taler_swift
     11 
     12 /// This view shows the action sheet
     13 /// optional:   [􀰟Spend KUDOS]
     14 ///   [􁉇 Send   ] [􁉅 Request]
     15 ///   [􁾩Deposit] [􁾭 Withdraw]
     16 ///        [􀎻  Scan QR ]
     17 struct ActionsSheet: View {
     18     let stack: CallStack
     19     @Binding var qrButtonTapped: Bool
     20 
     21     @Environment(\.openURL) private var openURL
     22     @EnvironmentObject private var controller: Controller
     23     @AppStorage("demoHints") var demoHints: Bool = true
     24 
     25     private var hasKudos: Bool {
     26         for balance in controller.balances {
     27             if balance.scopeInfo.currency == DEMOCURRENCY {
     28                 if !balance.available.isZero {
     29                     return true
     30                 }
     31             }
     32         }
     33         return false
     34     }
     35     private var mayDeposit: Bool { // returns true if at least 1 balance didn't disable deposits
     36         for balance in controller.balances {
     37             if !(balance.disableDirectDeposits == true) {       // false or nil
     38                 return true
     39             }
     40         }
     41         return false
     42     }
     43     private var mayP2P: Bool { // returns true if at least 1 balance didn't disable P2P
     44         for balance in controller.balances {
     45             if !(balance.disablePeerPayments == true) {         // false or nil
     46                 return true
     47             }
     48         }
     49         return false
     50     }
     51 
     52     var body: some View {
     53         VStack {
     54             let width = UIScreen.screenWidth / 3
     55             RoundedRectangle(cornerRadius: 8)                                   // dropBar
     56                 .foregroundColor(WalletColors().gray4)
     57                 .frame(width: width + 6.5, height: 5)
     58                 .padding(.top, 5)
     59                 .padding(.bottom, 10)
     60 
     61             if let balance = controller.balances.first,
     62                let shoppingUrls = balance.shoppingUrls,
     63                shoppingUrls.count > 0
     64             {
     65                 let currency = balance.scopeInfo.currency
     66                 ShoppingView(stack: stack.push(),
     67                           currency: currency,
     68                        buttonTitle: String(localized: "LinkTitle_SHOPS", defaultValue: "Where to pay with \(currency)"),
     69                     buttonA11yHint: String(localized: "Will go to the map of shops.", comment: "a11y"),
     70                         buttonLink: shoppingUrls.first!)
     71             } else if hasKudos && demoHints {
     72                 ShoppingView(stack: stack.push(),
     73                           currency: nil,
     74                        buttonTitle: String(localized: "LinkTitle_DEMOSHOP", defaultValue: "Spend demo money"),
     75                     buttonA11yHint: String(localized: "Will go to the demo shop website.", comment: "a11y"),
     76                         buttonLink: DEMOSHOP)
     77             }
     78             if !controller.scannedURLs.isEmpty {
     79                 Text("Scanned Taler codes:")
     80                     .talerFont(.body)
     81                     .multilineTextAlignment(.leading)
     82                     .fixedSize(horizontal: false, vertical: true)
     83                     .padding(.bottom, -2)
     84                 VStack {
     85                     ForEach(controller.scannedURLs) { scannedURL in
     86                         let action = {
     87                             dismissTop(stack.push())
     88                             DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
     89                                 // need to wait before opening the next sheet, otherwise SwiftUI doesn't update!
     90                                 openURL(scannedURL.url)
     91                             }
     92                         }
     93                         Button(action: action) {
     94                             HStack {
     95                                 Text(scannedURL.command.localizedCommand)
     96                                 ButtonIconBadge(type: scannedURL.command.transactionType,
     97                                            foreColor: .accentColor, done: false)
     98                                     .talerFont(.title2)
     99 
    100                                 Spacer()
    101                                 if let amount = scannedURL.amount {
    102                                     AmountV(scannedURL.scope, amount, isNegative: nil)
    103                                 } else if let baseURL = scannedURL.baseURL {
    104                                     Text(baseURL.trimURL)
    105                                 }
    106                             }
    107                             .padding(.horizontal, 8)
    108                         }
    109                         .buttonStyle(TalerButtonStyle(type: .bordered, narrow: false, aligned: .center))
    110                         .swipeActions(edge: .trailing) {
    111                             Button {
    112 //                                symLog?.log("deleteAction")
    113                                 Task { // runs on MainActor
    114                                     controller.removeURL(scannedURL.url)
    115                                 }
    116                             } label: {
    117                                 Label("Delete", systemImage: "trash")
    118                             }
    119                             .tint(WalletColors().negative)
    120                         }
    121 
    122                     }
    123                 }.padding(.bottom, 20)
    124             }
    125 
    126             let noBalances = controller.balances.count == 0
    127             let noP2P = !self.mayP2P
    128             let sendDisabled = noP2P
    129             let recvDisabled = noBalances || noP2P
    130             let depoDisabled = !mayDeposit
    131             ///   [􁉇 Send   ] [􁉅 Request]
    132             SendRequestV(stack: stack.push(), sendDisabled: sendDisabled, recvDisabled: recvDisabled)
    133                 .accessibility(sortPriority: 1)     // read this after maps
    134             ///   [􁾩Deposit] [􁾭 Withdraw]
    135             DepositWithdrawV(stack: stack.push(), sendDisabled: depoDisabled, recvDisabled: noBalances)
    136                 .accessibility(sortPriority: 0)     // read this last
    137             ///        [􀎻  Scan QR ]
    138             QRButton(hideTitle: false) {
    139                 qrButtonTapped = true
    140             }
    141                 .lineLimit(5)
    142                 .buttonStyle(TalerButtonStyle(type: .bordered, narrow: true, aligned: .center))
    143                 .accessibility(sortPriority: 3)     // read this first
    144         }
    145         .padding()
    146         .accessibilityElement(children: .contain)
    147     }
    148 }
    149 // MARK: -
    150 struct ShoppingView: View {
    151     let stack: CallStack
    152     let currency: String?
    153     let buttonTitle: String
    154     let buttonA11yHint: String
    155     let buttonLink: String
    156 
    157     @AppStorage("minimalistic") var minimalistic: Bool = false
    158 
    159     var body: some View {
    160         Group {
    161             if let currency {
    162                 Text(minimalistic ? "Spend your \(currency) in these shops:"
    163                                   : "You can spend your \(currency) in these shops, or send them to another wallet.")
    164             } else {
    165                 Text(minimalistic ? "Spend your \(DEMOCURRENCY) in the Demo shop"
    166                                   : "You can spend your \(DEMOCURRENCY) in the Demo shop, or send them to another wallet.")
    167             }
    168         }
    169             .talerFont(.body)
    170             .multilineTextAlignment(.leading)
    171             .fixedSize(horizontal: false, vertical: true)           // must set this otherwise fixedInnerHeight won't work
    172 
    173         let image = Image(systemName: currency == nil ? LINK : MAPPIN)          // 􀉣 or 􀎫
    174         Button("\(image) \(buttonTitle)") {
    175             UIApplication.shared.open(URL(string: buttonLink)!, options: [:])
    176             dismissTop(stack.push())
    177         }
    178             .buttonStyle(TalerButtonStyle(type: .bordered, narrow: false, aligned: .center))
    179             .accessibilityLabel(buttonTitle)
    180             .accessibilityAddTraits(.isLink)
    181             .accessibilityHint(buttonA11yHint)
    182             .padding(.bottom, 20)
    183     }
    184 }
    185 // MARK: -
    186 @available(iOS 16.4, *)
    187 struct DualHeightSheet: View {
    188     let stack: CallStack
    189     let selectedBalance: Balance?
    190     @Binding var qrButtonTapped: Bool
    191     let dismissScanner: () -> Void
    192 
    193     let logger = Logger(subsystem: "net.taler.gnu", category: "DualSheet")
    194     @Environment(\.colorScheme) private var colorScheme
    195     @Environment(\.colorSchemeContrast) private var colorSchemeContrast
    196 
    197     @AppStorage("minimalistic") var minimalistic: Bool = false
    198 //    @State private var selectedDetent: PresentationDetent = scanDetent        // Cannot use instance member 'scanDetent' within property initializer
    199     @State private var selectedDetent: PresentationDetent = .fraction(0.1)      // workaround - update in .task
    200     @State private var detents: Set<PresentationDetent> = [.fraction(0.1)]      //   "
    201     @State private var qrButtonTapped2: Bool = false
    202     @State private var scannedCode: Bool = false
    203     @State private var innerHeight: CGFloat = .zero
    204     @State private var sheetHeight: CGFloat = .zero
    205 
    206     let scanDetent: PresentationDetent = .fraction(SCANDETENT)
    207     let actionDetent: PresentationDetent = .fraction(ACTIONDETENT)
    208 
    209     func updateDetentsWithDelay() {
    210         Task {
    211             //(1 second = 1_000_000_000 nanoseconds)
    212             try? await Task.sleep(nanoseconds: 80_000_000)
    213             let detent = minimalistic ? scanDetent : actionDetent
    214             guard selectedDetent == detent else { return }
    215             detents = [detent]
    216             logger.trace("❗️detents = [scanDetent]")    // 0.999 %
    217         }
    218     }
    219 
    220     var body: some View {
    221         let scrollView = ScrollView {
    222             let background = colorScheme == .dark ? WalletColors().gray6
    223                                                   : WalletColors().gray2
    224             ActionsSheet(stack: stack.push(),
    225                 qrButtonTapped: $qrButtonTapped2)
    226             .presentationDragIndicator(.hidden)
    227             .presentationBackground {
    228                 background
    229                 /// overflow the bottom of the screen by a sufficient amount to fill the gap that is seen when the size changes
    230                     .padding(.bottom, -1000)
    231             }
    232             .innerHeight($innerHeight)
    233 
    234             .onChange(of: qrButtonTapped2) { tapped2 in
    235                 if tapped2 {
    236 //                    logger.trace("❗️the user tapped")
    237                     qrButtonTapped = true           // tell our caller
    238                     withAnimation(Animation.easeIn(duration: 0.6)) {
    239                         // animate this sheet to full height
    240                         let detent = minimalistic ? scanDetent : actionDetent
    241                         selectedDetent = detent
    242                     }
    243                 }
    244             }
    245             .onChange(of: qrButtonTapped) { tapped in
    246                 if !tapped {
    247                     logger.trace("❗️dismissed, cleanup")
    248                     sheetHeight = innerHeight
    249                     qrButtonTapped2 = false
    250                 }
    251             }
    252             .onChange(of: innerHeight) { newHeight in
    253                 logger.trace("onChange❗️set sheetHeight: \(sheetHeight) -> \(newHeight)❗️")
    254 //            withAnimation {
    255                 sheetHeight = newHeight
    256                 selectedDetent = .height(sheetHeight)       // will update detents in .onChange(:)
    257 //            }
    258             }
    259             .presentationDetents(detents, selection: $selectedDetent)
    260             .onChange(of: selectedDetent) { newValue in
    261                 let detent = minimalistic ? scanDetent : actionDetent
    262                 if newValue == detent {    // user swiped the sheet up to activate QR scanner
    263                     logger.trace("onChange❗️selectedDetent = \(SCANDETENT)")
    264                     updateDetentsWithDelay()
    265                     qrButtonTapped = true           // tell our caller
    266                 } else {    // SwiftUI "innerHeight" determined how big the half sheet should be
    267                     logger.trace("onChange❗️selectedDetent = .height(\(sheetHeight))")
    268                     detents = [detent, .height(sheetHeight)]
    269                 }
    270             }
    271             .task {
    272                 logger.trace("task❗️selectedDetent = .height(\(sheetHeight))")
    273                 selectedDetent = .height(sheetHeight)       // will update detents in .onChange(:)
    274             }
    275 //            .animation(.spring(), value: selectedDetent)
    276         }
    277         .edgesIgnoringSafeArea(.all)
    278         .frame(maxHeight: innerHeight)
    279 
    280         if #available(iOS 18.0, *) {
    281             scrollView
    282                 .sheet(isPresented: $qrButtonTapped,
    283                          onDismiss: dismissScanner
    284                 ) {
    285                     let qrSheet = AnyView(QRSheet(stack: stack.push(".sheet"),
    286                                         selectedBalance: selectedBalance,
    287                                        scannedSomething: $scannedCode))
    288                     let detent: PresentationDetent = .fraction(scannedCode ? ACTIONDETENT
    289                                                             : minimalistic ? SCANDETENT : ACTIONDETENT)
    290                     Sheet(stack: stack.push(), sheetView: qrSheet)
    291                         .presentationDetents([detent])
    292                         .transition(.opacity)
    293                 }
    294         } else {
    295             scrollView
    296         }
    297     }
    298 }