MainView.swift (29163B)
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 @Binding var soundPlayed: Bool 20 21 @EnvironmentObject private var controller: Controller 22 @EnvironmentObject private var model: WalletModel 23 @EnvironmentObject private var biometricService: BiometricService 24 25 #if DEBUG 26 @AppStorage("developerMode") var developerMode: Bool = true 27 #else 28 @AppStorage("developerMode") var developerMode: Bool = false 29 #endif 30 @AppStorage("minimalistic") var minimalistic: Bool = false 31 @AppStorage("talerFontIndex") var talerFontIndex: Int = 0 // extension mustn't define this, so it must be here 32 @AppStorage("playSoundsI") var playSoundsI: Int = 1 // extension mustn't define this, so it must be here 33 @AppStorage("playSoundsB") var playSoundsB: Bool = false 34 @AppStorage("useAuthentication") var useAuthentication: Bool = false 35 36 @StateObject private var tabBarModel = TabBarModel() 37 @State private var selectedBalance: Balance? = nil // for sheets, gets set in TransactionsListView 38 @State private var urlToOpen: URL? = nil 39 @State private var showUrlSheet = false 40 @State private var showActionSheet = false // Action button tapped 41 @State private var showScanner = false 42 // @State private var showCameraAlert: Bool = false 43 @State private var qrButtonTapped = false 44 @State private var qrButton2Tapped = false 45 @State private var scannedCode: Bool = false 46 @State private var innerHeight: CGFloat = .zero 47 @State private var userAction = 0 // make Action button jump, hold here 48 @State private var networkUnavailable = false 49 @State private var backgrounded: Date? // time we go into background 50 51 func sheetDismissed() -> Void { 52 logger.info("sheet dismiss") 53 symLog.log("sheet dismiss: \(urlToOpen)") 54 urlToOpen = nil 55 ViewState.shared.popToRootView(nil) 56 } 57 58 private func dismissSheet() { 59 showScanner = false 60 showActionSheet = false 61 qrButtonTapped = false 62 qrButton2Tapped = false 63 scannedCode = false 64 userAction += 1 65 // TODO: wallet-core could notify us when it creates a dialog tx 66 NotificationCenter.default.post(name: .TransactionScanned, object: nil, userInfo: nil) 67 } 68 69 func hintApplicationResumed() { 70 Task.detached { 71 await model.hintApplicationResumedT() 72 } 73 } 74 75 var body: some View { 76 #if PRINT_CHANGES 77 let _ = Self._printChanges() 78 let _ = symLog.vlog() // just to get the # to compare it with .onAppear & onDisappear 79 #endif 80 let mainContent = ZStack { 81 MainContent(logger: logger, stack: stack.push("Content"), 82 selectedBalance: $selectedBalance, 83 talerFontIndex: $talerFontIndex, 84 showActionSheet: $showActionSheet, 85 showScanner: $showScanner, 86 qrButtonTapped: $qrButtonTapped, 87 userAction: $userAction) 88 .onAppear() { 89 #if DEBUG 90 if playSoundsI != 0 && playSoundsB && !soundPlayed { 91 controller.playSound(1008) 92 } 93 #endif 94 soundPlayed = true 95 } // Startup chime 96 .overlay(alignment: .top) { 97 DebugViewV() 98 .id("ViewID") 99 } // Show the viewID on top of the app's NavigationView 100 101 if (!showScanner && urlToOpen == nil) { 102 if let error2 = model.error2 { 103 ErrorView(stack.push("Main"), data: error2, devMode: developerMode) { 104 model.setError(nil) 105 }.interactiveDismissDisabled() 106 .background(WalletColors().backgroundColor.edgesIgnoringSafeArea(.all)) 107 // .transition(.move(edge: .top)) 108 // } else { 109 // Color.clear 110 } 111 } 112 } 113 .overlay { 114 if useAuthentication && !biometricService.isAuthenticated { 115 Color.gray.opacity(0.75) 116 .animation(.easeInOut, value: biometricService.isAuthenticated) 117 if let errorMessage = biometricService.authenticationError { 118 Text(errorMessage) 119 .talerFont(.title) 120 .foregroundColor(.red) 121 .multilineTextAlignment(.center) 122 .padding() 123 .background(WalletColors().backgroundColor) 124 .onTapGesture { 125 biometricService.authenticationError = nil 126 biometricService.authenticateUser() 127 } 128 .onAppear { 129 DispatchQueue.main.asyncAfter(deadline: .now() + 3) { 130 biometricService.authenticationError = nil 131 if !biometricService.canAuthenticate { 132 let _ = print("authentication not available") 133 useAuthentication = false // switch off 134 } 135 } 136 } 137 } else { 138 Image(TALER_LOGO) 139 .resizable() 140 .scaledToFit() 141 .frame(width: 100, height: 100) 142 .onAppear { 143 biometricService.authenticateUser() 144 } 145 .onTapGesture { 146 biometricService.authenticateUser() 147 } 148 } 149 } 150 } 151 152 let mainGroup = Group { 153 // show launch animation until either ready or error 154 switch controller.backendState { 155 case .ready: mainContent 156 case .error: ErrorView(stack.push("mainGroup"), 157 title: EMPTYSTRING, // TODO: String(localized: ""), 158 copyable: true) {} 159 default: LaunchAnimationView() 160 } 161 }.animation(.linear(duration: LAUNCHDURATION), value: controller.backendState) 162 163 VStack { 164 if networkUnavailable { 165 Text("Network unavailable!") 166 .foregroundStyle(.white) 167 .frame(maxWidth: .infinity, alignment: .leading) 168 .padding() 169 .background { 170 RoundedRectangle(cornerRadius: 15) 171 .fill(Color.red) 172 } 173 .padding(.horizontal) 174 .transition(.asymmetric( 175 insertion: .move(edge: .top), 176 removal: .move(edge: .top) 177 )) 178 } 179 180 mainGroup 181 .environmentObject(tabBarModel) 182 // .animation(.default, value: model.error2 == nil) 183 .sheet(isPresented: $showUrlSheet, onDismiss: sheetDismissed) { 184 let sheet = URLSheet(stack: stack.push(), 185 selectedBalance: selectedBalance, 186 urlToOpen: $urlToOpen) 187 .id("onOpenURL") 188 Sheet(stack: stack.push(), sheetView: AnyView(sheet)) 189 } 190 .sheet(isPresented: $showScanner, 191 onDismiss: dismissSheet 192 ) { 193 let qrSheet = AnyView(QRSheet(stack: stack.push(".sheet"), 194 selectedBalance: selectedBalance, 195 scannedSomething: $scannedCode)) 196 // let _ = logger.trace("❗️showScanner: \(SCANDETENT)❗️") 197 if #available(iOS 16.4, *) { 198 let detent: PresentationDetent = .fraction(scannedCode ? ACTIONDETENT 199 : minimalistic ? SCANDETENT : ACTIONDETENT) 200 Sheet(stack: stack.push(), sheetView: qrSheet) 201 .presentationDetents([detent]) 202 .transition(.opacity) 203 } else { 204 Sheet(stack: stack.push(), sheetView: qrSheet) 205 .transition(.opacity) 206 } 207 } 208 .sheet(isPresented: $showActionSheet, 209 onDismiss: dismissSheet 210 ) { 211 if #available(iOS 16.4, *) { 212 // let _ = logger.trace("❗️actionsSheet: small❗️ (showScanner == false)") 213 if #available(iOS 18.0, *) { 214 DualHeightSheet(stack: stack.push(), 215 selectedBalance: selectedBalance, 216 qrButtonTapped: $qrButton2Tapped, 217 dismissScanner: dismissSheet) // needs to explicitely dismiss 2nd sheet 218 } else { 219 DualHeightSheet(stack: stack.push(), 220 selectedBalance: selectedBalance, 221 qrButtonTapped: $qrButtonTapped, 222 dismissScanner: {})//dismissSheet) 223 } 224 } else { 225 Group { 226 Spacer(minLength: 1) 227 ScrollView { 228 ActionsSheet(stack: stack.push(), 229 qrButtonTapped: $qrButtonTapped) 230 .innerHeight($innerHeight) 231 // .padding() 232 } 233 .frame(maxHeight: innerHeight) 234 .edgesIgnoringSafeArea(.all) 235 } 236 .background(WalletColors().gray2) 237 } // iOS 15 238 } 239 } 240 #if OIM 241 .onRotate { newOrientation in 242 let isSheetActive = showActionSheet || showScanner || showUrlSheet 243 controller.setOIMmode(for: newOrientation, isSheetActive) 244 tabBarModel.oimActive = controller.oimModeActive ? 1 : 0 245 } 246 .onChange(of: showScanner) { newShowScan in 247 let isSheetActive = showActionSheet || showScanner || showUrlSheet 248 controller.setOIMmode(for: UIDevice.current.orientation, isSheetActive) 249 tabBarModel.oimActive = controller.oimModeActive ? 1 : 0 250 } 251 #endif 252 .onOpenURL { url in 253 symLog.log(".onOpenURL: \(url)") 254 // will be called on a taler:// scheme either 255 // by user tapping such link in a browser (bank website) 256 // or when launching the app from iOS Camera.app scanning a QR code 257 urlToOpen = url 258 DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { 259 showUrlSheet = true // raise sheet 260 } 261 } 262 .onChange(of: controller.talerURI) { url in 263 if url != nil { 264 urlToOpen = url 265 DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { 266 controller.talerURI = nil 267 showUrlSheet = true // raise sheet 268 } } } 269 270 .onChange(of: qrButtonTapped) { tapped in 271 if tapped { 272 let delay = if #available(iOS 16.4, *) { 0.5 } else { 0.01 } 273 withAnimation(Animation.easeOut(duration: 0.5).delay(delay)) { 274 showScanner = true // switch to qrSheet => camera on 275 } } } 276 .onChange(of: controller.isConnected) { isConnected in 277 if isConnected { 278 withAnimation(.easeIn(duration: 1.0)) { 279 networkUnavailable = false 280 } 281 } else { 282 withAnimation(.easeOut(duration: 1.0)) { 283 networkUnavailable = true 284 } } } 285 286 .onReceive(NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification, object: nil)) { _ in 287 logger.log("❗️App Will Resign") 288 backgrounded = Date.now 289 showScanner = false 290 } 291 .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification, object: nil)) { _ in 292 logger.log("❗️App Will Enter Foreground") 293 if let backgrounded { 294 let interval = Date.now - backgrounded 295 if interval.seconds > 60 { 296 biometricService.isAuthenticated = false 297 if interval.seconds > 300 { // 5 minutes 298 logger.log("More than 5 minutes in background - tell wallet-core") 299 hintApplicationResumed() 300 } 301 } 302 } 303 backgrounded = nil 304 } 305 .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification, object: nil)) { _ in 306 logger.log("❗️App Did Become Active") 307 } 308 309 } // body 310 } 311 // MARK: - TabBar 312 enum Tab: Int, Hashable, CaseIterable { 313 case balances = 0 314 case actions 315 case settings 316 317 // var index: Int { self.rawValue } 318 319 var title: String { 320 switch self { 321 case .balances: String(localized: "TitleBalances", defaultValue: "Balances") 322 case .actions: String(localized: "TitleActions", defaultValue: "Actions") 323 case .settings: String(localized: "TitleSettings", defaultValue: "Settings") 324 } 325 } 326 327 var image: Image { 328 switch self { 329 case .balances: return Image(systemName: BALANCES) // "chart.bar.xaxis" 330 case .actions: return Image(TALER_LOGO) 331 case .settings: return Image(systemName: SETTINGS) // "gear" 332 } 333 } 334 335 var a11y: String { 336 switch self { 337 case .balances: BALANCES // "chart.bar.xaxis" 338 case .actions: "bolt" 339 case .settings: SETTINGS // "gear" 340 } 341 } 342 343 var label: Label<Text, Image> { 344 Label(self) 345 } 346 } 347 // MARK: - 348 class NamespaceWrapper: ObservableObject { 349 var namespace: Namespace.ID 350 351 init(_ namespace: Namespace.ID) { 352 self.namespace = namespace 353 } 354 } 355 // MARK: - 356 extension Label where Title == Text, Icon == Image { 357 init(_ tab: Tab) { 358 self.init(EMPTYSTRING, systemImage: tab.a11y) 359 } 360 } 361 // MARK: - 362 struct ActionType: Hashable { 363 let animationDisabled: Bool 364 } 365 // MARK: - Content 366 extension MainView { 367 368 struct MainContent: View { 369 let logger: Logger 370 let stack: CallStack 371 @Binding var selectedBalance: Balance? 372 @Binding var talerFontIndex: Int 373 @Binding var showActionSheet: Bool 374 @Binding var showScanner: Bool 375 @Binding var qrButtonTapped: Bool 376 @Binding var userAction: Int // make Action button jump 377 378 @EnvironmentObject private var controller: Controller 379 @EnvironmentObject private var model: WalletModel 380 @EnvironmentObject private var tabBarModel: TabBarModel 381 @EnvironmentObject private var viewState: ViewState // popToRootView() 382 @EnvironmentObject private var viewState2: ViewState2 // popToRootView() 383 #if DEBUG 384 @AppStorage("developerMode") var developerMode: Bool = true 385 #else 386 @AppStorage("developerMode") var developerMode: Bool = false 387 #endif 388 @AppStorage("minimalistic") var minimalistic: Bool = false 389 @AppStorage("oimEuro") var oimEuro: Bool = false 390 391 @State private var shouldReloadBalances = 0 392 @State private var shouldReloadTransactions = 0 393 // @State private var shouldReloadPending = 0 394 @State private var selectedTab: Tab = .balances 395 @State private var showKycAlert: Bool = false 396 @State private var kycURI: URL? 397 398 @State private var amountToTransfer = Amount.zero(currency: EMPTYSTRING) // Update currency when used 399 @State private var amountLastUsed = Amount.zero(currency: EMPTYSTRING) // Update currency when used 400 @State private var summary: String = EMPTYSTRING 401 @State private var iconID: String? = nil 402 403 private var openKycButton: some View { 404 Button("Legitimization") { 405 showKycAlert = false 406 if let kycURI { 407 UIApplication.shared.open(kycURI) 408 } else { 409 // YIKES! 410 } 411 } 412 } 413 private var dismissAlertButton: some View { 414 Button("Cancel", role: .cancel) { 415 showKycAlert = false 416 } 417 } 418 419 private func tabSelection() -> Binding<Tab> { 420 Binding { //this is the get block 421 self.selectedTab 422 } set: { tappedTab in 423 if tappedTab == self.selectedTab { 424 // User tapped on the tab twice == Pop to root view 425 switch tappedTab { 426 case .balances: 427 ViewState.shared.popToRootView(nil) 428 case .settings: 429 ViewState2.shared.popToRootView(nil) 430 default: 431 break 432 } 433 // if homeNavigationStack.isEmpty { 434 //User already on home view, scroll to top 435 // } else { 436 // homeNavigationStack = [] 437 // } 438 } else { // Set the tab to the tabbed tab 439 self.selectedTab = tappedTab 440 } 441 } 442 } 443 private var isBalances: Bool { self.selectedTab == .balances} 444 private func triggerAction(_ action: Int) { 445 tabBarModel.actionSelected = isBalances ? action // 1..4 446 : action + 4 // 5..8 447 } 448 449 private static func className() -> String {"\(self)"} 450 private static var name: String { Self.className() } 451 452 private var tabContent: some View { 453 /// Destinations for the 4 actions 454 let sendDest = SendAmountV(stack: stack.push(Self.name), 455 selectedBalance: $selectedBalance, // if nil shows currency picker 456 amountLastUsed: $amountLastUsed, // currency needs to be updated! 457 summary: $summary, 458 iconID: $iconID, 459 oimEuro: oimEuro) 460 let requestDest = RequestPayment(stack: stack.push(Self.name), 461 selectedBalance: selectedBalance, 462 amountLastUsed: $amountLastUsed, // currency needs to be updated! 463 summary: $summary, 464 iconID: $iconID, 465 oimEuro: oimEuro) 466 let depositDest = DepositSelectV(stack: stack.push(Self.name), 467 selectedBalance: selectedBalance, 468 amountLastUsed: $amountLastUsed) 469 let manualWithdrawDest = ManualWithdraw(stack: stack.push(Self.name), 470 url: nil, 471 selectedBalance: selectedBalance, 472 amountLastUsed: $amountLastUsed, // currency needs to be updated! 473 amountToTransfer: $amountToTransfer, 474 exchange: nil, // only for withdraw-exchange 475 maySwitchCurrencies: true, 476 isSheet: false) 477 /// each NavigationView needs its own NavLinks 478 let balanceActions = Group { // actionSelected will hide the tabBar 479 NavLink(1, $tabBarModel.actionSelected) { sendDest } 480 NavLink(2, $tabBarModel.actionSelected) { requestDest } 481 NavLink(3, $tabBarModel.actionSelected) { depositDest } 482 NavLink(4, $tabBarModel.actionSelected) { manualWithdrawDest } 483 } 484 let settingsActions = Group { 485 NavLink(5, $tabBarModel.actionSelected) { sendDest } 486 NavLink(6, $tabBarModel.actionSelected) { requestDest } 487 NavLink(7, $tabBarModel.actionSelected) { depositDest } 488 NavLink(8, $tabBarModel.actionSelected) { manualWithdrawDest } 489 } 490 /// tab titles, and invisible tabItems which are only used for a11y 491 let balancesTitle = Tab.balances.title // "Balances" 492 let actionTitle = Tab.actions.title // "Actions" 493 let settingsTitle = Tab.settings.title // "Settings" 494 let a11yBalanceTab = Label(Tab.balances).labelStyle(.titleOnly) 495 .accessibilityLabel(balancesTitle) 496 let a11yActionsTab = Label(Tab.actions).labelStyle(.titleOnly) 497 .accessibilityLabel(actionTitle) 498 let a11ySettingsTab = Label(Tab.settings).labelStyle(.titleOnly) 499 .accessibilityLabel(settingsTitle) 500 /// NavigationViews for Balances & Settings 501 let balancesStack = NavigationView { 502 BalancesListView(stack: stack.push(balancesTitle), 503 title: balancesTitle, 504 selectedBalance: $selectedBalance, // needed for sheets, gets set in TransactionsListView 505 reloadTransactions: $shouldReloadTransactions, 506 qrButtonTapped: $qrButtonTapped) 507 .background(balanceActions) 508 }.navigationViewStyle(.stack) 509 let settingsStack = NavigationView { 510 SettingsView(stack: stack.push(), 511 navTitle: settingsTitle) 512 .background(settingsActions) 513 }.navigationViewStyle(.stack) 514 /// the tabItems (EMPTYSTRING .titleOnly) could indeed be omitted and the app would work the same - but are needed for accessibilityLabel 515 return TabView(selection: tabSelection()) { 516 balancesStack.id(viewState.rootViewId) // change rootViewId to trigger popToRootView behaviour 517 .tag(Tab.balances) 518 .tabItem { a11yBalanceTab } 519 Color.clear // can't use EmptyView: VoiceOver wouldn't have the Actions tab 520 .tag(Tab.actions) 521 .tabItem { a11yActionsTab } 522 settingsStack.id(viewState2.rootViewId) // change rootViewId to trigger popToRootView behaviour 523 .tag(Tab.settings) 524 .tabItem { a11ySettingsTab } 525 } // TabView 526 } 527 528 func onActionTab() { 529 logger.log("onActionTab: showActionSheet = true") 530 controller.removeURLs(after: 60*60) // TODO: specify time after which scanned URLs are deleted 531 showActionSheet = true 532 } 533 534 func onActionDrag() { 535 logger.log("onActionDrag: showScanner = true") 536 showScanner = true 537 } 538 539 var body: some View { 540 #if PRINT_CHANGES 541 // "@self" marks that the view value itself has changed, and "@identity" marks that the 542 // identity of the view has changed (that is, that the persistent data associated with 543 // the view has been recycled for a new instance of the same type) 544 if #available(iOS 17.1, *) { 545 // logs at INFO level, “com.apple.SwiftUI” subsystem, category “Changed Body Properties” 546 let _ = Self._logChanges() 547 } else { 548 let _ = Self._printChanges() 549 } 550 #endif 551 // custom tabBar with the Actions button in the middle 552 let tabBarView = TabBarView(selection: tabSelection(), 553 userAction: $userAction, 554 hidden: $tabBarModel.tabBarHidden, 555 onActionTab: onActionTab, 556 onActionDrag: onActionDrag) 557 ZStack(alignment: .bottom) { 558 tabContent // incl. the (transparent) SwiftUI tabBar 559 tabBarView // custom tabBar is rendered on top of the TabView, and overlaps its tabBar 560 .ignoresSafeArea(.keyboard, edges: .bottom) 561 .accessibilityHidden(true) // for a11y we use the original tabBar, not our custom one 562 } // ZStack 563 .frame(maxWidth: .infinity, maxHeight: .infinity) 564 .onNotification(.SendAction) { notification in 565 if let actionType = notification.userInfo?[NOTIFICATIONANIMATION] as? ActionType { 566 if actionType.animationDisabled { 567 var transaction = Transaction() 568 transaction.disablesAnimations = true 569 withTransaction(transaction) { 570 triggerAction(1) 571 } 572 } else { triggerAction(1) } 573 } else { triggerAction(1) } 574 } 575 .onNotification(.RequestAction) { triggerAction(2) } 576 .onNotification(.DepositAction) { triggerAction(3) } 577 .onNotification(.WithdrawAction){ triggerAction(4) } 578 .onNotification(.KYCrequired) { notification in 579 // show an alert with the KYC link (button) which opens in Safari 580 if let transition = notification.userInfo?[TRANSACTIONTRANSITION] as? TransactionTransition { 581 if let kycString = transition.experimentalUserData { 582 if let urlForKYC = URL(string: kycString) { 583 logger.log(".onNotification(.KYCrequired): \(kycString)") 584 kycURI = urlForKYC 585 showKycAlert = true 586 } 587 } else { 588 // TODO: no KYC URI 589 } 590 } 591 } 592 .alert("You need to pass a legitimization procedure.", 593 isPresented: $showKycAlert, 594 actions: { openKycButton 595 dismissAlertButton }, 596 message: { Text("Tap the button to go to the legitimization website.") }) 597 .onNotification(.BalanceChange) { notification in 598 logger.info(".onNotification(.BalanceChange) ==> reload balances") 599 shouldReloadBalances += 1 600 } 601 .onNotification(.TransactionExpired) { notification in 602 logger.info(".onNotification(.TransactionExpired) ==> reload balances") 603 shouldReloadTransactions += 1 604 // shouldReloadPending += 1 605 } 606 .onNotification(.TransactionScanned) { 607 shouldReloadTransactions += 1 608 } 609 .onNotification(.TransactionDone) { 610 shouldReloadTransactions += 1 611 // shouldReloadPending += 1 612 // selectedTab = .balances // automatically switch to Balances 613 } 614 .onNotification(.TransactionError) { notification in 615 // shouldReloadPending += 1 616 } 617 .onNotification(.GeneralError) { notification in 618 if let error = notification.userInfo?[NOTIFICATIONERROR] as? Error { 619 model.setError(error) 620 controller.playSound(0) 621 } 622 } 623 .task(id: shouldReloadBalances) { 624 // symLog.log(".task shouldReloadBalances \(shouldReloadBalances)") 625 await controller.loadBalances(stack.push("refreshing balances"), model) 626 } // task 627 } // body 628 } // Content 629 }