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 }