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 }