taler-ios

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

ActionsSheet.swift (16390B)


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