taler-ios

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

ActionsSheet.swift (14966B)


      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                         let isEnabled = UIPasteboard.general.hasURLs
    148                         PasteButton(accessibilityLabelStr: "Paste") {
    149                             Task {
    150                                 TalerWallet1App().inspectPasteboard(playSound: true)
    151                             }
    152                         }.buttonStyle(TalerButtonStyle(type: .bordered,
    153                                                      dimmed: false,
    154                                                      narrow: true,
    155                                                    disabled: !isEnabled,
    156                                                     aligned: .center))
    157                         .accessibility(sortPriority: 1)
    158                     }
    159                     Spacer(minLength: 8)
    160                 }
    161                 ///   [􀎻  Scan QR ]
    162                 QRButton(hideTitle: false) {
    163                     Task {
    164                         NotificationCenter.default.post(name: .QrScanAction, object: nil)   // will
    165                     }
    166                 }
    167                     .lineLimit(5)
    168                     .buttonStyle(TalerButtonStyle(type: .bordered, narrow: true, aligned: .center))
    169                     .accessibility(sortPriority: 3)     // read this first
    170             }
    171         }
    172         .padding(.top)
    173         .padding(.horizontal)
    174         .padding(.bottom, -8)
    175         .accessibilityElement(children: .contain)
    176     }
    177 }
    178 // MARK: -
    179 struct ShoppingView: View {
    180     let stack: CallStack
    181     let currency: String?
    182     let buttonTitle: String
    183     let buttonA11yHint: String
    184     let buttonLink: String
    185 
    186     @AppStorage("minimalistic") var minimalistic: Bool = false
    187 
    188     var body: some View {
    189         Group {
    190             if let currency {
    191                 Text(minimalistic ? "Spend your \(currency) in these shops:"
    192                                   : "You can spend your \(currency) in these shops, or send them to another wallet.")
    193             } else {
    194                 Text(minimalistic ? "Spend your \(DEMOCURRENCY) in the Demo shop"
    195                                   : "You can spend your \(DEMOCURRENCY) in the Demo shop, or send them to another wallet.")
    196             }
    197         }
    198             .talerFont(.body)
    199             .multilineTextAlignment(.leading)
    200             .fixedSize(horizontal: false, vertical: true)           // must set this otherwise fixedInnerHeight won't work
    201 
    202         let image = Image(systemName: currency == nil ? LINK : MAPPIN)          // 􀉣 or 􀎫
    203         Button("\(image) \(buttonTitle)") {
    204             UIApplication.shared.open(URL(string: buttonLink)!, options: [:])
    205             dismissTop(stack.push())
    206         }
    207             .buttonStyle(TalerButtonStyle(type: .bordered, narrow: false, aligned: .center))
    208             .accessibilityLabel(buttonTitle)
    209             .accessibilityAddTraits(.isLink)
    210             .accessibilityHint(buttonA11yHint)
    211             .padding(.bottom, 20)
    212     }
    213 }
    214 // MARK: -
    215 @available(iOS 16.4, *)
    216 struct DualHeightSheet: View {
    217     let stack: CallStack
    218     let selectedBalance: Balance?
    219     let dismissScanner: () -> Void
    220 
    221     let logger = Logger(subsystem: "net.taler.gnu", category: "DualSheet")
    222     @Environment(\.colorScheme) private var colorScheme
    223     @Environment(\.colorSchemeContrast) private var colorSchemeContrast
    224 
    225     @AppStorage("minimalistic") var minimalistic: Bool = false
    226 //    @State private var selectedDetent: PresentationDetent = scanDetent        // Cannot use instance member 'scanDetent' within property initializer
    227     @State private var selectedDetent: PresentationDetent = .fraction(0.1)      // workaround - update in .task
    228     @State private var supportedDetents: Set<PresentationDetent> = [.fraction(0.1)]      //   "
    229 //    @State private var qrButtonTapped2: Bool = false
    230     @State private var scannedCode: Bool = false
    231     @State private var innerHeight: CGFloat = .zero
    232     @State private var sheetHeight: CGFloat = .zero
    233 
    234     let halfDetent: PresentationDetent = .fraction(HALFDETENT)
    235     let fullDetent: PresentationDetent = .fraction(FULLDETENT)
    236 
    237     func updateDetentsWithDelay() {
    238         Task {
    239             //(1 second = 1_000_000_000 nanoseconds)
    240             try? await Task.sleep(nanoseconds: 80_000_000)
    241             let detent = minimalistic ? halfDetent : fullDetent
    242             guard selectedDetent == detent else { return }
    243             supportedDetents = [detent]
    244             logger.trace("updateDetentsWithDelay❗️supportedDetents = [scanDetent]")      // 0.999 %
    245         }
    246     }
    247 
    248     func actionsSheet() -> some View {
    249         let actionsSheet = ActionsSheet(stack: stack.push())
    250                             .presentationDragIndicator(.visible)
    251         if #available(iOS 28.0, *) {        // TODO: adapt ActionsSheet() to glass-buttons
    252             return actionsSheet
    253                 .presentationSizing(.fitted) // iOS 18+
    254         } else {
    255             let background = colorScheme == .dark ? WalletColors().gray6
    256                                                   : WalletColors().gray2
    257             return actionsSheet
    258                 .presentationBackground {
    259                     background
    260                     /// overflow the bottom of the screen by a sufficient amount to fill the gap that is seen when the size changes
    261                         .padding(.bottom, -1000)
    262                 }
    263         }
    264     }
    265 
    266     var body: some View {
    267         let scrollView = ScrollView {
    268             actionsSheet()
    269             .innerHeight($innerHeight)
    270 //            .onChange(of: qrButtonTapped2) { tapped2 in
    271 //                if tapped2 {
    272 ////                    logger.trace("❗️the user tapped")
    273 //                    NotificationCenter.default.post(name: .QrScanAction, object: nil)   // will trigger
    274 //                    withAnimation(Animation.easeIn(duration: 0.6)) {
    275 //                        // animate this sheet to full height
    276 //                        let detent = minimalistic ? halfDetent : fullDetent
    277 //                        logger.trace("qrButtonTapped2❗️selected_Detent = \(FULLDETENT)")
    278 //                        selectedDetent = detent
    279 //                    }
    280 //                }
    281 //            }
    282 //            .onChange(of: qrButtonTapped) { tapped in
    283 //                if !tapped {
    284 //                    logger.trace("❗️dismissed, cleanup")
    285 //                    sheetHeight = innerHeight
    286 //                    qrButtonTapped2 = false
    287 //                }//tapped
    288 //            }//qr
    289             .onChange(of: innerHeight) { newHeight in
    290                 logger.trace("onChange❗️innerHeight => set sheetHeight: \(sheetHeight) -> \(newHeight)❗️")
    291                 sheetHeight = newHeight
    292                 selectedDetent = .height(newHeight)         // will update supportedDetents in .onChange(:)
    293                 logger.trace("did set selectedDetent and sheetHeight to new innerHeight")
    294             }
    295             .presentationDetents(supportedDetents, selection: $selectedDetent)
    296             .onChange(of: selectedDetent) { newValue in
    297                 let detent = minimalistic ? halfDetent : fullDetent
    298                 if newValue == detent {    // user swiped the sheet up to activate QR scanner
    299                     logger.trace("onChange❗️selectedDetent == (detent) ==> updateDetentsWithDelay()")
    300                     updateDetentsWithDelay()
    301                     NotificationCenter.default.post(name: .QrScanAction, object: nil)   // will trigger
    302                 } else {    // SwiftUI "innerHeight" determined how big the half sheet should be
    303                     logger.trace("onChange❗️selectedDetent = .height(\(sheetHeight))  supportedDetents = [detent, .height(sheetHeight)]")
    304                     supportedDetents = [detent, newValue]
    305 //                    logger.trace("did set supportedDetents to [\(detent), .height(\(sheetHeight))]")
    306                     let _ = print("did set supportedDetents to [\(detent), newValue: \(newValue))]")
    307                 }
    308             }
    309             .task {
    310                 logger.trace("task❗️selectedDetent = .height(\(sheetHeight))")
    311                 selectedDetent = .height(sheetHeight)       // will update supportedDetents in .onChange(:)
    312             }
    313 //            .animation(.spring(), value: selectedDetent)
    314         }
    315         .ignoresSafeArea()
    316         .frame(maxHeight: innerHeight)
    317 
    318         if #available(iOS 18.0, *) {
    319             scrollView
    320 //                .sheet(isPresented: $qrButtonTapped,
    321 //                         onDismiss: dismissScanner
    322 //                ) {
    323 //                    let qrSheet = AnyView(QRSheet(stack: stack.push(".sheet"),
    324 //                                        selectedBalance: selectedBalance,
    325 //                                       scannedSomething: $scannedCode))
    326 //                    let detent: PresentationDetent = .fraction(scannedCode ? FULLDETENT
    327 //                                                            : minimalistic ? HALFDETENT : FULLDETENT)
    328 //                    Sheet(stack: stack.push(), sheetView: qrSheet)
    329 //                        .presentationDetents([detent])
    330 //                        .transition(.opacity)
    331 //                }
    332                 .defaultScrollAnchor(.top)
    333         } else {
    334             scrollView
    335         }
    336     }
    337 }