taler-ios

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

MainView.swift (21256B)


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