taler-ios

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

MainView.swift (16469B)


      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     func hintApplicationResumed() {
     70         Task.detached {
     71             await model.hintApplicationResumedT()
     72         }
     73     }
     74 
     75     @ViewBuilder func qrSheet() -> some View {
     76         let qrSheet = AnyView(QRSheet(stack: stack.push(".sheet"),
     77                             selectedBalance: selectedBalance,
     78                            scannedSomething: $scannedCode))
     79 //        let _ = logger.trace("❗️showScanner: \(SCANDETENT)❗️")
     80         if #available(iOS 16.4, *) {
     81             let detent: PresentationDetent = .fraction(scannedCode ? FULLDETENT
     82                                                     : minimalistic ? HALFDETENT : FULLDETENT)
     83             let sheet = Sheet(stack: stack.push(), sheetView: qrSheet)
     84                 .presentationDetents([detent])
     85                 .transition(.opacity)
     86 
     87             if #available(iOS 18.0, *) {
     88                 sheet
     89                     .navigationTransition(
     90                         .zoom(sourceID: "unique_transition_id", in: namespace)
     91                     )
     92             } else {
     93                 sheet
     94             }   // iOS 16 + 17
     95         } else {
     96             Sheet(stack: stack.push(), sheetView: qrSheet)
     97                 .transition(.opacity)
     98         } // iOS 15
     99     }
    100 
    101     @ViewBuilder func actionSheet() -> some View {
    102         if #available(iOS 16.4, *) {
    103 //            let _ = logger.trace("❗️actionsSheet: small❗️ (showScanner == false)")
    104             if #available(iOS 18.0, *) {
    105                 DualHeightSheet(stack: stack.push(),
    106                       selectedBalance: selectedBalance,
    107                        dismissScanner: dismissSheet)        // needs to explicitely dismiss 2nd sheet
    108                 // TODO: this is commented out because of the weird behavior when switching to the QR scanner.
    109                 // Once we have ONE sheet with different items, enable this again
    110 //                .navigationTransition(
    111 //                    .zoom(sourceID: "unique_transition_id", in: namespace)
    112 //                )
    113             } else {
    114                 DualHeightSheet(stack: stack.push(),
    115                       selectedBalance: selectedBalance,
    116                        dismissScanner: {})//dismissSheet)
    117             } // iOS 16 + 17
    118         } else {
    119             Group {
    120                 Spacer(minLength: 1)    // leave space for VoiceOver dismiss
    121                 ScrollView {
    122                     ActionsSheet(stack: stack.push())
    123                         .innerHeight($innerHeight)
    124                 }
    125                 .frame(maxHeight: innerHeight)
    126                 .ignoresSafeArea()
    127             }.background(WalletColors().gray2)
    128         } // iOS 15
    129     }
    130 
    131     var body: some View {
    132 #if PRINT_CHANGES
    133         let _ = Self._printChanges()
    134         let _ = symLog.vlog()       // just to get the # to compare it with .onAppear & onDisappear
    135 #endif
    136         let mainContent = ZStack {
    137             WalletMain(logger: logger, stack: stack.push("Content"),
    138               selectedBalance: $selectedBalance,
    139                talerFontIndex: $talerFontIndex,
    140               showActionSheet: $showActionSheet,
    141                   showScanner: $showScanner)
    142             .environmentObject(NamespaceWrapper(namespace))
    143             .overlay(alignment: .top) {
    144                 DebugViewV()
    145             }     // Show the viewID on top of the app's NavigationView
    146 
    147             if (!showScanner && urlToOpen == nil) {
    148                 if let error2 = model.error2 {
    149                     ErrorView(stack.push("Main"), data: error2, devMode: developerMode) {
    150                         model.setError(nil)
    151                     }.interactiveDismissDisabled()
    152                      .background(FullBackground())
    153 //                   .transition(.move(edge: .top))
    154 //              } else {
    155 //                  Color.clear
    156                 }
    157             }
    158         }
    159             .overlay {
    160                 if useAuthentication && !biometricService.isAuthenticated {
    161                     Color.gray.opacity(0.75)
    162                         .animation(.easeInOut, value: biometricService.isAuthenticated)
    163                     if let errorMessage = biometricService.authenticationError {
    164                         Text(errorMessage)
    165                             .talerFont(.title)
    166                             .foregroundColor(.red)
    167                             .multilineTextAlignment(.center)
    168                             .padding()
    169                             .background(WalletColors().backgroundColor)
    170                             .onTapGesture {
    171                                 biometricService.authenticationError = nil
    172                                 biometricService.authenticateUser()
    173                             }
    174                             .onAppear {
    175                                 DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
    176                                     biometricService.authenticationError = nil
    177                                     if !biometricService.canAuthenticate {
    178                                         let _ = print("authentication not available")
    179                                         useAuthentication = false           // switch off
    180                                     }
    181                                 }
    182                             }
    183                     } else {
    184                         Image(TALER_LOGO)
    185                             .resizable()
    186                             .scaledToFit()
    187                             .frame(width: 100, height: 100)
    188                             .onAppear {
    189                                 biometricService.authenticateUser()
    190                             }
    191                             .onTapGesture {
    192                                 biometricService.authenticateUser()
    193                             }
    194                     }
    195                 }
    196             }   // biometrics
    197 
    198         let mainGroup = Group {
    199             // show launch animation until either ready or error
    200             switch controller.backendState {
    201                 case .ready: mainContent
    202                 case .error(let error): ErrorView(stack.push("mainGroup"),
    203                                                   title: EMPTYSTRING,   // TODO: String(localized: ""),
    204                                                copyable: true) {}
    205                 default:     LaunchAnimationView()
    206             }
    207         }.animation(.linear(duration: LAUNCHDURATION), value: controller.backendState)
    208 
    209         VStack {
    210             if controller.networkUnavailable {
    211                 ErrorBanner(.networkUnavailable)        // red bar on top
    212             } else if controller.slowConnection {
    213                 ErrorBanner(.warning5sec)               // yellow bar on top
    214             }
    215             mainGroup
    216                 .environmentObject(tabBarModel)
    217 //              .animation(.default, value: model.error2 == nil)
    218                 .sheet(item: $sheetType,
    219                   onDismiss: sheetDismissed) { sheet in
    220                     switch sheet {
    221                         case .action:
    222                             actionSheet()
    223                         case .scanner:
    224                             qrSheet()
    225                                 .environmentObject(tabBarModel)
    226                         case .url(let url):
    227                             let uSheet = URLSheet(stack.push(),
    228                                         selectedBalance: selectedBalance,
    229                                                sheetURL: url)
    230                                 .id("onOpenURL")
    231                             Sheet(stack: stack.push(), sheetView: AnyView(uSheet))
    232                                 .environmentObject(tabBarModel)
    233                     }
    234                 }
    235                 .sheet(isPresented: $showUrlSheet,
    236                          onDismiss: sheetDismissed) {
    237                     let sheet = URLSheet(stack.push(),
    238                                selectedBalance: selectedBalance,
    239                                      urlToOpen: $urlToOpen)
    240                         .id("onOpenURL")
    241                     Sheet(stack: stack.push(), sheetView: AnyView(sheet))
    242                         .environmentObject(tabBarModel)
    243                 }   // UrlSheet
    244                 .sheet(isPresented: $showScanner,
    245                          onDismiss: dismissSheet) {
    246                     qrSheet()
    247                         .environmentObject(tabBarModel)
    248                 }   // QR Scanner
    249                 .sheet(isPresented: $showActionSheet,
    250                          onDismiss: dismissActionSheet
    251                 ) {
    252                     actionSheet()
    253                         .environmentObject(tabBarModel)
    254                 }   // ActionSheet
    255         }   // VStack { networkUnavailable + mainGroup }
    256 #if OIM
    257         // set controller.oimModeActive
    258         .onRotate { newOrientation in
    259             let isSheetActive = showActionSheet || showScanner || showUrlSheet
    260             controller.setOIMmode(for: newOrientation, isSheetActive)
    261             tabBarModel.oimActive = controller.oimModeActive ? 1 : 0
    262         }
    263         .onChange(of: showScanner) { newShowScan in
    264             let isSheetActive = showActionSheet || showScanner || showUrlSheet
    265             controller.setOIMmode(for: UIDevice.current.orientation, isSheetActive)
    266             tabBarModel.oimActive = controller.oimModeActive ? 1 : 0
    267         }
    268 #endif
    269         .onNotification(.QrScanAction) {
    270             let delay = if #available(iOS 16.4, *) { 0.3 } else { 0.01 }
    271             logger.info("QrScanAction notification: showScanner = true")
    272             withAnimation(Animation.easeOut(duration: 0.5).delay(delay)) {
    273                 showActionSheet = false
    274                 showScanner = true      // switch to qrSheet => camera on
    275         }   }
    276 
    277         .onNotification(.PasteAction) { notification in
    278             if let notifData = notification.userInfo?[NOTIFICATIONPASTE] as? PasteType {
    279                 symLog.log(".PasteAction: \(notifData)")
    280                 urlToOpen = notifData.pastedURL
    281                 DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
    282                     showUrlSheet = true     // raise sheet
    283         }   }   }
    284 
    285         .onOpenURL { url in
    286             symLog.log(".onOpenURL: \(url)")
    287             // will be called on a taler:// scheme either
    288             // by user tapping such link in a browser (bank website)
    289             // or when launching the app from iOS Camera.app scanning a QR code
    290             urlToOpen = url
    291             DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
    292                 showUrlSheet = true     // raise sheet
    293         }   }
    294 
    295         .onChange(of: controller.talerURI) { url in
    296             if url != nil {
    297                 urlToOpen = url
    298                 DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
    299                     controller.talerURI = nil
    300                     showUrlSheet = true     // raise sheet
    301         }   }   }
    302 
    303         .onNotification(.RequestProgressError) { notification in
    304             if let progress = notification.userInfo?[NOTIFICATIONERROR] as? RequestProgressError {
    305                 withAnimation(.easeIn(duration: BANNERDURATION)) {
    306                     controller.errorConnection = true
    307 //                    print("❗️❗️❗️  progress.error: \(progress.error.code) ")
    308                 }
    309         }   }
    310 
    311         .onNotification(.RequestProgressPhase) { notification in
    312             if let progress = notification.userInfo?[NOTIFICATIONPHASE] as? RequestProgressPhase {
    313 //                print(progress)
    314                 switch progress.phase {
    315                     case .delayed:
    316                         withAnimation(.easeOut(duration: BANNERDURATION)) {
    317                             controller.slowConnection = true
    318                             controller.stalledConnection = false
    319                         }
    320                     case .stalled:
    321                         withAnimation(.easeIn(duration: BANNERDURATION)) {
    322                             controller.slowConnection = false
    323                             controller.stalledConnection = true
    324                         }
    325                     default:
    326                         withAnimation(.easeIn(duration: BANNERDURATION)) {
    327                             controller.slowConnection = false
    328                             controller.stalledConnection = false
    329                         }
    330         }   }   }
    331 
    332         .onChange(of: controller.isConnected) { isConnected in
    333             if isConnected {
    334                 withAnimation(.easeIn(duration: BANNERDURATION)) {
    335                     controller.networkUnavailable = false
    336                 }
    337             } else {
    338                 withAnimation(.easeOut(duration: BANNERDURATION)) {
    339                     controller.networkUnavailable = true
    340         }   }   }
    341 
    342         .onReceive(NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification, object: nil)) { _ in
    343             logger.log("❗️App Will Resign")
    344             backgrounded = Date.now
    345             showScanner = false
    346         }   // App Will Resign
    347         .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification, object: nil)) { _ in
    348             logger.log("❗️App Will Enter Foreground")
    349             if let backgrounded {
    350                 let interval = Date.now - backgrounded
    351                 if interval.seconds > 60 {
    352                     biometricService.isAuthenticated = false
    353                     if interval.seconds > 300 {     // 5 minutes
    354                         logger.log("More than 5 minutes in background - tell wallet-core")
    355                         hintApplicationResumed()
    356                     }
    357                 }
    358             }
    359             backgrounded = nil
    360         }   // App Will Enter Foreground
    361         .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification, object: nil)) { _ in
    362             logger.log("❗️App Did Become Active")
    363         }   // App Did Become Active
    364     } // body
    365 }
    366 // MARK: -
    367 class NamespaceWrapper: ObservableObject {
    368     var namespace: Namespace.ID
    369 
    370     init(_ namespace: Namespace.ID) {
    371         self.namespace = namespace
    372     }
    373 }