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 }