taler-ios

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

ActionsSheet.swift (16728B)


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