taler-ios

iOS apps for GNU Taler (wallet)
Log | Files | Refs | README | LICENSE

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 }