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