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 }