taler-ios

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

MainView.swift (15055B)


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