taler-ios

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

ActionsSheet.swift (14951B)


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