taler-ios

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

MainView.swift (18636B)


      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  * @author Iván Ávalos
      8  */
      9 import SwiftUI
     10 import os.log
     11 import SymLog
     12 import AVFoundation
     13 import taler_swift
     14 
     15 struct MainView: View {
     16     private let symLog = SymLogV(0)
     17     let logger: Logger
     18     let stack: CallStack
     19 
     20     @EnvironmentObject private var controller: Controller
     21     @EnvironmentObject private var model: WalletModel
     22     @EnvironmentObject private var biometricService: BiometricService
     23 
     24 #if DEBUG
     25     @AppStorage("developerMode") var developerMode: Bool = true
     26 #else
     27     @AppStorage("developerMode") var developerMode: Bool = false
     28 #endif
     29     @AppStorage("minimalistic") var minimalistic: Bool = false
     30     @AppStorage("talerFontIndex") var talerFontIndex: Int = 0       // extension mustn't define this, so it must be here
     31     @AppStorage("useAuthentication") var useAuthentication: Bool = false
     32 
     33     @StateObject private var tabBarModel = TabBarModel()
     34     @State private var selectedBalance: Balance? = nil      // for sheets, gets set in TransactionsListView
     35     @State private var urlToOpen: URL? = nil
     36     @State private var sheetType: SheetType?
     37     @State private var showUrlSheet = false
     38     @State private var showActionSheet = false              // Action button tapped
     39     @State private var showScanner = false
     40 //    @State private var showCameraAlert: Bool = false
     41     @State private var scannedCode: Bool = false
     42     @State private var innerHeight: CGFloat = .zero
     43     @State private var backgrounded: Date?                  // time we go into background
     44     @Namespace private var namespace
     45 
     46     func sheetDismissed() -> Void {
     47         logger.info("sheetDismissed")
     48         symLog.log("sheet dismiss: \(urlToOpen)")
     49         urlToOpen = nil
     50         ViewState.shared.popToRootView(nil)
     51     }
     52 
     53     private func dismissSheet() {
     54         logger.info("dismissSheet")
     55         showScanner = false
     56         showActionSheet = false
     57         scannedCode = false
     58         controller.userAction += 1                      // make Action button jump
     59         // TODO: wallet-core could notify us when it creates a dialog tx
     60         NotificationCenter.default.post(name: .TransactionScanned, object: nil, userInfo: nil)
     61     }
     62 
     63     private func dismissActionSheet() {
     64         logger.info("dismissActionSheet")
     65         showActionSheet = false
     66         controller.userAction += 1                      // make Action button jump
     67     }
     68 
     69     /// A sheet is a modal presentation over the whole window, thus it would be shown *above*
     70     /// the biometrics overlay. Route every sheet through the lock instead, so that nothing
     71     /// can be presented while the wallet is locked - a sheet asked for while locked stays
     72     /// pending and is only shown once the user has authenticated.
     73     private func unlocked(_ isPresented: Binding<Bool>, _ locked: Bool) -> Binding<Bool> {
     74         Binding(get: { !locked && isPresented.wrappedValue },
     75                 set: { isPresented.wrappedValue = $0 })
     76     }
     77 
     78     private func unlocked<Item>(_ item: Binding<Item?>, _ locked: Bool) -> Binding<Item?> {
     79         Binding(get: { locked ? nil : item.wrappedValue },
     80                 set: { item.wrappedValue = $0 })
     81     }
     82 
     83     func hintApplicationResumed() {
     84         Task.detached {
     85             if let result = try? await model.hintApplicationResumedT() {
     86                 if !result.dbReadHealthy {
     87                     let error = TalerErrorDetail(code: 53,  // GENERIC_DB_FETCH_FAILED
     88                                                  when: .now(),
     89                                                  hint: "Can't read from database")
     90                     await model.setError(WalletBackendError.walletCoreError(error))
     91                 } else if !result.dbWriteHealthy {
     92                     let error = TalerErrorDetail(code: 52,  // GENERIC_DB_STORE_FAILED
     93                                                  when: .now(),
     94                                                  hint: "Can't write to database")
     95                     await model.setError(WalletBackendError.walletCoreError(error))
     96                 }
     97             }
     98         }
     99     }
    100 
    101     @ViewBuilder func qrSheet() -> some View {
    102         let qrSheet = AnyView(QRSheet(stack: stack.push(".sheet"),
    103                             selectedBalance: selectedBalance,
    104                            scannedSomething: $scannedCode))
    105 //        let _ = logger.trace("❗️showScanner: \(SCANDETENT)❗️")
    106         if #available(iOS 16.4, *) {
    107             let detent: PresentationDetent = .fraction(scannedCode ? FULLDETENT
    108                                                     : minimalistic ? HALFDETENT : FULLDETENT)
    109             let sheet = Sheet(stack: stack.push(), sheetView: qrSheet)
    110                 .presentationDetents([detent])
    111                 .transition(.opacity)
    112 
    113             if #available(iOS 18.0, *) {
    114                 sheet
    115                     .navigationTransition(
    116                         .zoom(sourceID: "unique_transition_id", in: namespace)
    117                     )
    118             } else {
    119                 sheet
    120             }   // iOS 16 + 17
    121         } else {
    122             Sheet(stack: stack.push(), sheetView: qrSheet)
    123                 .transition(.opacity)
    124         } // iOS 15
    125     }
    126 
    127     @ViewBuilder func actionSheet() -> some View {
    128         if #available(iOS 16.4, *) {
    129 //            let _ = logger.trace("❗️actionsSheet: small❗️ (showScanner == false)")
    130             if #available(iOS 18.0, *) {
    131                 DualHeightSheet(stack: stack.push(),
    132                       selectedBalance: selectedBalance,
    133                        dismissScanner: dismissSheet)        // needs to explicitely dismiss 2nd sheet
    134                 // TODO: this is commented out because of the weird behavior when switching to the QR scanner.
    135                 // Once we have ONE sheet with different items, enable this again
    136 //                .navigationTransition(
    137 //                    .zoom(sourceID: "unique_transition_id", in: namespace)
    138 //                )
    139             } else {
    140                 DualHeightSheet(stack: stack.push(),
    141                       selectedBalance: selectedBalance,
    142                        dismissScanner: {})//dismissSheet)
    143             } // iOS 16 + 17
    144         } else {
    145             Group {
    146                 Spacer(minLength: 1)    // leave space for VoiceOver dismiss
    147                 ScrollView {
    148                     ActionsSheet(stack: stack.push())
    149                         .innerHeight($innerHeight)
    150                 }
    151                 .frame(maxHeight: innerHeight)
    152                 .ignoresSafeArea()
    153             }.background(WalletColors().gray2)
    154         } // iOS 15
    155     }
    156 
    157     var body: some View {
    158 #if PRINT_CHANGES
    159         let _ = Self._printChanges()
    160         let _ = symLog.vlog()       // just to get the # to compare it with .onAppear & onDisappear
    161 #endif
    162         let locked = useAuthentication && !biometricService.isAuthenticated
    163         let mainContent = ZStack {
    164             WalletMain(logger: logger, stack: stack.push("Content"),
    165               selectedBalance: $selectedBalance,
    166                talerFontIndex: $talerFontIndex,
    167               showActionSheet: $showActionSheet,
    168                   showScanner: $showScanner)
    169             .environmentObject(NamespaceWrapper(namespace))
    170             .overlay(alignment: .top) {
    171                 DebugViewV()
    172             }     // Show the viewID on top of the app's NavigationView
    173 
    174             if (!showScanner && urlToOpen == nil) {
    175                 if let error2 = model.error2 {
    176                     ErrorView(stack.push("Main"), data: error2, devMode: developerMode) {
    177                         model.setError(nil)
    178                     }.interactiveDismissDisabled()
    179                      .background(FullBackground())
    180 //                   .transition(.move(edge: .top))
    181 //              } else {
    182 //                  Color.clear
    183                 }
    184             }
    185         }
    186             .overlay {
    187                 if locked {
    188                     Color.gray.opacity(0.75)
    189                         .animation(.easeInOut, value: biometricService.isAuthenticated)
    190                     if let errorMessage = biometricService.authenticationError {
    191                         Text(errorMessage)
    192                             .talerFont(.title)
    193                             .foregroundColor(.red)
    194                             .multilineTextAlignment(.center)
    195                             .padding()
    196                             .background(WalletColors().backgroundColor)
    197                             .onTapGesture {
    198                                 biometricService.authenticationError = nil
    199                                 biometricService.authenticateUser()
    200                             }
    201                             .onAppear {
    202                                 DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
    203                                     biometricService.authenticationError = nil
    204                                     if !biometricService.canAuthenticate {
    205                                         let _ = print("authentication not available")
    206                                         useAuthentication = false           // switch off
    207                                     }
    208                                 }
    209                             }
    210                     } else {
    211                         Image(TALER_LOGO)
    212                             .resizable()
    213                             .scaledToFit()
    214                             .frame(width: 100, height: 100)
    215                             .onAppear {
    216                                 biometricService.authenticateUser()
    217                             }
    218                             .onTapGesture {
    219                                 biometricService.authenticateUser()
    220                             }
    221                     }
    222                 }
    223             }   // biometrics
    224 
    225         let mainGroup = Group {
    226             // show launch animation until either ready or error
    227             switch controller.backendState {
    228                 case .ready: mainContent
    229                 case .error(let error):
    230                     ErrorView(stack.push("mainGroup"),
    231                               title: "Launch error",   // TODO: String(localized: "")
    232                             message: error.localizedDescription,
    233                            copyable: true
    234                     ) {}
    235                 default:     LaunchAnimationView()
    236             }
    237         }.animation(.linear(duration: LAUNCHDURATION), value: controller.backendState)
    238 
    239         VStack {
    240             if controller.networkUnavailable {
    241                 ErrorBanner(.networkUnavailable)        // red bar on top
    242             } else if controller.slowConnection {
    243                 ErrorBanner(.warning5sec)               // yellow bar on top
    244             }
    245             mainGroup
    246                 .environmentObject(tabBarModel)
    247 //              .animation(.default, value: model.error2 == nil)
    248                 .sheet(item: unlocked($sheetType, locked),
    249                   onDismiss: sheetDismissed) { sheet in
    250                     switch sheet {
    251                         case .action:
    252                             actionSheet()
    253                         case .scanner:
    254                             qrSheet()
    255                                 .environmentObject(tabBarModel)
    256                         case .url(let url):
    257                             let uSheet = URLSheet(stack.push(),
    258                                         selectedBalance: selectedBalance,
    259                                                sheetURL: url)
    260                                 .id("onOpenURL")
    261                             Sheet(stack: stack.push(), sheetView: AnyView(uSheet))
    262                                 .environmentObject(tabBarModel)
    263                     }
    264                 }
    265                 .sheet(isPresented: unlocked($showUrlSheet, locked),
    266                          onDismiss: sheetDismissed) {
    267                     let sheet = URLSheet(stack.push(),
    268                                selectedBalance: selectedBalance,
    269                                      urlToOpen: $urlToOpen)
    270                         .id("onOpenURL")
    271                     Sheet(stack: stack.push(), sheetView: AnyView(sheet))
    272                         .environmentObject(tabBarModel)
    273                 }   // UrlSheet
    274                 .sheet(isPresented: unlocked($showScanner, locked),
    275                          onDismiss: dismissSheet) {
    276                     qrSheet()
    277                         .environmentObject(tabBarModel)
    278                 }   // QR Scanner
    279                 .sheet(isPresented: unlocked($showActionSheet, locked),
    280                          onDismiss: dismissActionSheet
    281                 ) {
    282                     actionSheet()
    283                         .environmentObject(tabBarModel)
    284                 }   // ActionSheet
    285         }   // VStack { networkUnavailable + mainGroup }
    286 #if OIM
    287         // set controller.oimModeActive
    288         .onRotate { newOrientation in
    289             let isSheetActive = showActionSheet || showScanner || showUrlSheet
    290             controller.setOIMmode(for: newOrientation, isSheetActive)
    291             tabBarModel.oimActive = controller.oimModeActive ? 1 : 0
    292         }
    293         .onChange(of: showScanner) { newShowScan in
    294             let isSheetActive = showActionSheet || showScanner || showUrlSheet
    295             controller.setOIMmode(for: UIDevice.current.orientation, isSheetActive)
    296             tabBarModel.oimActive = controller.oimModeActive ? 1 : 0
    297         }
    298 #endif
    299         .onNotification(.QrScanAction) {
    300             let delay = if #available(iOS 16.4, *) { 0.3 } else { 0.01 }
    301             logger.info("QrScanAction notification: showScanner = true")
    302             withAnimation(Animation.easeOut(duration: 0.5).delay(delay)) {
    303                 showActionSheet = false
    304                 showScanner = true      // switch to qrSheet => camera on
    305         }   }
    306 
    307         .onNotification(.PasteAction) { notification in
    308             if let notifData = notification.userInfo?[NOTIFICATIONPASTE] as? PasteType {
    309                 symLog.log(".PasteAction: \(notifData)")
    310                 urlToOpen = notifData.pastedURL
    311                 DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
    312                     showUrlSheet = true     // raise sheet
    313         }   }   }
    314 
    315         .onOpenURL { url in
    316             symLog.log(".onOpenURL: \(url)")
    317             // will be called on a taler:// scheme either
    318             // by user tapping such link in a browser (bank website)
    319             // or when launching the app from iOS Camera.app scanning a QR code
    320             urlToOpen = url
    321             DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
    322                 showUrlSheet = true     // raise sheet
    323         }   }
    324 
    325         .onChange(of: controller.talerURI) { url in
    326             if url != nil {
    327                 urlToOpen = url
    328                 DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
    329                     controller.talerURI = nil
    330                     showUrlSheet = true     // raise sheet
    331         }   }   }
    332 
    333         .onNotification(.RequestProgressError) { notification in
    334             if let progress = notification.userInfo?[NOTIFICATIONERROR] as? RequestProgressError {
    335                 withAnimation(.easeIn(duration: BANNERDURATION)) {
    336                     controller.errorConnection = true
    337 //                    print("❗️❗️❗️  progress.error: \(progress.error.code) ")
    338                 }
    339         }   }
    340 
    341         .onNotification(.RequestProgressPhase) { notification in
    342             if let progress = notification.userInfo?[NOTIFICATIONPHASE] as? RequestProgressPhase {
    343 //                print(progress)
    344                 switch progress.phase {
    345                     case .delayed:
    346                         withAnimation(.easeOut(duration: BANNERDURATION)) {
    347                             controller.slowConnection = true
    348                             controller.stalledConnection = false
    349                         }
    350                     case .stalled:
    351                         withAnimation(.easeIn(duration: BANNERDURATION)) {
    352                             controller.slowConnection = false
    353                             controller.stalledConnection = true
    354                         }
    355                     default:
    356                         withAnimation(.easeIn(duration: BANNERDURATION)) {
    357                             controller.slowConnection = false
    358                             controller.stalledConnection = false
    359                         }
    360         }   }   }
    361 
    362         .onChange(of: controller.isConnected) { isConnected in
    363             if isConnected {
    364                 withAnimation(.easeIn(duration: BANNERDURATION)) {
    365                     controller.networkUnavailable = false
    366                 }
    367             } else {
    368                 withAnimation(.easeOut(duration: BANNERDURATION)) {
    369                     controller.networkUnavailable = true
    370         }   }   }
    371 
    372         .onReceive(NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification, object: nil)) { _ in
    373             logger.log("❗️App Will Resign")
    374             backgrounded = Date.now
    375             showScanner = false
    376         }   // App Will Resign
    377         .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification, object: nil)) { _ in
    378             logger.log("❗️App Will Enter Foreground")
    379             if let backgrounded {
    380                 let interval = Date.now - backgrounded
    381                 if interval.seconds > 10 {      // enough time to paste copied data and return
    382                     // TODO: add another toggle with snail/hare for 60 sec / 10 sec
    383                     biometricService.isAuthenticated = false
    384                     if useAuthentication {      // we lock now: a sheet which was open before
    385                         sheetType = nil         // must not come back after authentication
    386                         showUrlSheet = false    // (showScanner is already off, see Resign)
    387                         showActionSheet = false
    388                     }
    389                 }
    390                 if interval.seconds > 30 {
    391                     logger.log("More than 30 seconds in background - tell wallet-core")
    392                     hintApplicationResumed()
    393                 }
    394             }
    395             backgrounded = nil
    396         }   // App Will Enter Foreground
    397         .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification, object: nil)) { _ in
    398             logger.log("❗️App Did Become Active")
    399         }   // App Did Become Active
    400     } // body
    401 }
    402 // MARK: -
    403 class NamespaceWrapper: ObservableObject {
    404     var namespace: Namespace.ID
    405 
    406     init(_ namespace: Namespace.ID) {
    407         self.namespace = namespace
    408     }
    409 }