taler-ios

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

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 }