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 }