taler-ios

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

ActionsSheet.swift (14886B)


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