taler-ios

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

MainView.swift (20993B)


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