taler-ios

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

WalletMain.swift (18028B)


      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 // MARK: -
     16 struct ActionType: Hashable {
     17     let animationDisabled: Bool
     18 }
     19 // MARK: - Content
     20 
     21 struct WalletMain: View {
     22     let logger: Logger
     23     let stack: CallStack
     24     @Binding var selectedBalance: Balance?
     25     @Binding var talerFontIndex: Int
     26 #if TALER_NIGHTLY
     27     @Binding var sheetType: SheetType?
     28 #else
     29     @Binding var showActionSheet: Bool
     30     @Binding var showScanner: Bool
     31 #endif
     32     @EnvironmentObject private var controller: Controller
     33     @EnvironmentObject private var model: WalletModel
     34     @EnvironmentObject private var tabBarModel: TabBarModel
     35     @EnvironmentObject private var viewState: ViewState     // popToRootView()
     36     @EnvironmentObject private var viewState2: ViewState2     // popToRootView()
     37     @EnvironmentObject private var wrapper: NamespaceWrapper
     38     @Environment(\.accessibilityVoiceOverEnabled) private var voiceOverEnabled
     39 #if DEBUG
     40     @AppStorage("developerMode") var developerMode: Bool = true
     41 #else
     42     @AppStorage("developerMode") var developerMode: Bool = false
     43 #endif
     44     @AppStorage("minimalistic") var minimalistic: Bool = false
     45     @AppStorage("tapped") var tapped: Int = 0
     46     @AppStorage("oimEuro") var oimEuro: Bool = false
     47 
     48     @State private var shouldReloadBalances = 0
     49     @State private var shouldReloadTransactions = 0
     50     @State private var selectedTab: TalerTab = .balances
     51     @State private var showKycAlert: Bool = false
     52     @State private var kycURI: URL?
     53 
     54     @State private var amountToTransfer = Amount.zero(currency: EMPTYSTRING)  // Update currency when used
     55     @State private var amountLastUsed = Amount.zero(currency: EMPTYSTRING)    // Update currency when used
     56     @State private var summary: String = EMPTYSTRING
     57     @State private var iconID: String? = nil
     58     @State private var showRealCashHint = true
     59 
     60     private var openKycButton: some View {
     61         Button("Legitimization") {
     62             showKycAlert = false
     63             if let kycURI {
     64                 UIApplication.shared.open(kycURI)
     65             } else {
     66                 // YIKES!
     67             }
     68         }
     69     }
     70 
     71     private var dismissAlertButton: some View {
     72         Button("Cancel", role: .cancel) {
     73             showKycAlert = false
     74         }
     75     }
     76 
     77 //    @available(iOS, deprecated: 26.0)
     78     // \|/ this doesn't work - should give a compiler warning when called from 26+, but doesn't
     79     @available(iOS, obsoleted: 26.0)
     80     private func tabSelection() -> Binding<TalerTab> {
     81         Binding { //this is the get block
     82             self.selectedTab
     83         } set: { tappedTab in
     84             if tappedTab == self.selectedTab {
     85                 // User tapped on the tab twice == Pop to root view
     86                 switch tappedTab {
     87                     case .balances:
     88                         ViewState.shared.popToRootView(nil)
     89                     case .settings:
     90                         ViewState2.shared.popToRootView(nil)
     91                     case .actions:
     92                         if #available(iOS 26.0, *) {
     93                             logger.log(level: .debug, "TODO: show Action sheet again if it's not active already")
     94 
     95                         } else {
     96                             break
     97                         }
     98                 }
     99 //                if homeNavigationStack.isEmpty {
    100                     // User already on home view, scroll to top
    101 //                } else {
    102                     // pop to root
    103 //                    homeNavigationStack = []
    104 //                }
    105             } else {    // Set the tab to the tabbed tab
    106                 self.selectedTab = tappedTab
    107                 if #available(iOS 26.0, *) {
    108                     if tappedTab == .actions {
    109                         logger.log(level: .debug, "TODO: show Action sheet")
    110 
    111                     }
    112                 } // else the custom tabBar will handle the Action sheet
    113             }
    114         }
    115     }
    116 
    117     private var isBalances: Bool { self.selectedTab == .balances}
    118     private func triggerAction(_ action: Int) {
    119         tabBarModel.actionSelected = action < 0 ? action
    120                                    : isBalances ? action        // 1..4
    121                                                 : action + 4    // 5..8
    122     }
    123 
    124     private static func className() -> String {"\(self)"}
    125     private static var name: String { Self.className() }
    126 
    127     private var tabContent: some View {
    128         /// Destinations for the 4 actions
    129         let sendDest = SendAmountV(stack: stack.push(Self.name),
    130                          selectedBalance: $selectedBalance,                 // if nil shows currency picker
    131                           amountLastUsed: $amountLastUsed,                  // currency needs to be updated!
    132                                  summary: $summary,
    133                                   iconID: $iconID,
    134                                  oimEuro: oimEuro)
    135         let requestDest = RequestPayment(stack: stack.push(Self.name),
    136                                selectedBalance: selectedBalance,
    137                                 amountLastUsed: $amountLastUsed,            // currency needs to be updated!
    138                                        summary: $summary,
    139                                         iconID: $iconID,
    140                                        oimEuro: oimEuro)
    141         let depositDest = DepositSelectV(stack: stack.push(Self.name),
    142                                selectedBalance: selectedBalance,
    143                                 amountLastUsed: $amountLastUsed)
    144         let manualWithdrawDest = ManualWithdraw(stack: stack.push(Self.name),
    145                                                   url: nil,
    146                                       selectedBalance: selectedBalance,
    147                                        amountLastUsed: $amountLastUsed,     // currency needs to be updated!
    148                                      amountToTransfer: $amountToTransfer,
    149                                              exchange: nil,                 // only for withdraw-exchange
    150                                   maySwitchCurrencies: true,
    151                                               isSheet: false)
    152         /// tab titles
    153         let balancesTitle = TalerTab.balances.title     // "Balances"
    154         let actionTitle = TalerTab.actions.title        // "Actions"
    155         let settingsTitle = TalerTab.settings.title     // "Settings"
    156                                                         /// each NavigationView needs its own NavLinks
    157         let balanceActions = Group {                                        // actionSelected will hide the tabBar
    158             NavLink(1, $tabBarModel.actionSelected) { sendDest }
    159             NavLink(2, $tabBarModel.actionSelected) { requestDest }
    160             NavLink(3, $tabBarModel.actionSelected) { depositDest }
    161             NavLink(4, $tabBarModel.actionSelected) { manualWithdrawDest }
    162             if #available(iOS 26.0, *) {
    163                 NavLink(-1, $tabBarModel.actionSelected) {
    164                     SettingsView(stack: stack.push(),
    165                                  navTitle: settingsTitle)
    166                 }
    167             }
    168         }
    169         let settingsActions = Group {
    170             NavLink(5, $tabBarModel.actionSelected) { sendDest }
    171             NavLink(6, $tabBarModel.actionSelected) { requestDest }
    172             NavLink(7, $tabBarModel.actionSelected) { depositDest }
    173             NavLink(8, $tabBarModel.actionSelected) { manualWithdrawDest }
    174         }
    175         /// NavigationViews for Balances & Settings
    176         let balancesStack = NavigationView {
    177             ZStack(alignment: .trailing) {
    178                 if controller.balances.isEmpty {
    179                     WalletEmptyHeader(stack: stack.push(), showRealCashHint: $showRealCashHint)
    180                 } else {
    181 //                    if #available(iOS 17.0, *) {
    182 //                        CarouselView(stack: stack.push(balancesTitle),
    183 //                           selectedBalance: $selectedBalance,    // needed for sheets, gets set in TransactionsListView
    184 //                        reloadTransactions: $shouldReloadTransactions)
    185 //                    } else {   // Fallback on earlier versions
    186                     BalancesListView(stack: stack.push(balancesTitle),
    187                                      title: balancesTitle,
    188                            selectedBalance: $selectedBalance,    // needed for sheets, gets set in TransactionsListView
    189                           showRealCashHint: $showRealCashHint,
    190                         reloadTransactions: $shouldReloadTransactions)
    191                     .accessibilitySortPriority(1) // Reads third
    192 //                    } iOS 15 + 16
    193                 }
    194                 if #available(iOS 26.0, *) {
    195                     // floating Settings & Action buttons
    196                     let buttons = VStack(alignment: .trailing) {
    197                         if voiceOverEnabled {
    198                             SettingsButton26(sortPriority: 0)
    199                         }
    200                         Spacer()
    201                         ActionItem(stack: stack.push(),
    202                                      tab: TalerTab.actions,
    203                                    onTap: onActionTab,
    204                                   onDrag: onActionDrag)
    205                         .accessibilitySortPriority(2) // Reads second
    206                         .matchedTransitionSource(id: "unique_transition_id", in: wrapper.namespace)
    207 //                        .navigationTransition(.zoom(sourceID: "unique_transition_id", in: wrapper.namespace))
    208                     }
    209                         .padding(.horizontal)
    210 #if OIM
    211                     if !controller.oimModeActive {  // hide for OIM
    212                         buttons
    213                     }
    214 #else
    215                     buttons
    216 #endif
    217                 } // iOS 26
    218             }.background(balanceActions)
    219 
    220         }.navigationViewStyle(.stack)
    221             .accessibilitySortPriority(3) // Reads first
    222 
    223         if #available(iOS 26.0, *) {
    224             // no TabView, just a single NavigationView, containing the BalancesListView
    225             // with floating Liquid Glass Actions button
    226             return balancesStack.id(viewState.rootViewId)      // change rootViewId to trigger popToRootView behaviour
    227         } else {
    228             // iOS 15..18: TabView with 3 tabs, middle is Actions
    229             let settingsStack = NavigationView {
    230                 SettingsView(stack: stack.push(),
    231                           navTitle: settingsTitle)
    232                 .background(settingsActions)
    233             }.navigationViewStyle(.stack)
    234             /// invisible tabItems, used for a11y
    235             let a11yBalanceTab = TalerTab.balances.label
    236                 .accessibilityLabel(balancesTitle)
    237                 .labelStyle(.titleOnly)
    238             let a11yActionsTab = Label(TalerTab.actions)
    239                 .accessibilityLabel(actionTitle)
    240                 .labelStyle(.titleOnly)
    241             let a11ySettingsTab = Label(TalerTab.settings)
    242                 .accessibilityLabel(settingsTitle)
    243                 .labelStyle(.titleOnly)
    244             return TabView(selection: tabSelection()) {
    245                 balancesStack.id(viewState.rootViewId)      // change rootViewId to trigger popToRootView behaviour
    246                     .tag(TalerTab.balances)
    247                     .tabItem { a11yBalanceTab }
    248                 Color.clear                                 // can't use EmptyView: VoiceOver wouldn't have the Actions tab
    249                     .tag(TalerTab.actions)
    250                     .tabItem { a11yActionsTab }
    251                 settingsStack.id(viewState2.rootViewId)     // change rootViewId to trigger popToRootView behaviour
    252                     .tag(TalerTab.settings)
    253                     .tabItem { a11ySettingsTab }
    254             } // TabView
    255         }
    256     }
    257 
    258     func onActionTab() {
    259         logger.log("onActionTab: showActionSheet = true")
    260         controller.removeURLs(after: 60*60)         // TODO: specify time after which scanned URLs are deleted
    261         withAnimation {
    262 #if TALER_NIGHTLY
    263             sheetType = .action
    264 #else
    265             showActionSheet = true
    266 #endif
    267         }
    268     }
    269 
    270 #if TALER_NIGHTLY
    271     var showScanner: Bool { sheetType == .scanner }
    272 #endif
    273     func onActionDrag(_ stack: CallStack) {
    274         if !showScanner {                           // gets called multiple times - log only once
    275 #if DEBUG
    276             if let caller = stack.peek() {
    277                 logger.log("showScanner = true (onActionDrag: \(caller.file):\(caller.function))")
    278             }
    279 #endif
    280             withAnimation {
    281 #if TALER_NIGHTLY
    282                 sheetType = .scanner
    283 #else
    284                 showScanner = true
    285 #endif
    286             }
    287         }
    288     }
    289 
    290     func tabBarView() -> some View {
    291         // iOS 15..18: custom tabBar (with Actions button) is rendered on top of the TabView,
    292         // and overlays the native iOS tabBar (which is still used for VoiceOver)
    293         TabBarView(stack: stack.push(),
    294                selection: tabSelection(),
    295                   hidden: $tabBarModel.tabBarHidden,
    296              onActionTab: onActionTab,
    297             onActionDrag: onActionDrag)
    298         .ignoresSafeArea(.keyboard, edges: .bottom)
    299         .accessibilityHidden(true)                  // for a11y we use the original tabBar, not our custom one
    300     }
    301 
    302     var body: some View {
    303 #if PRINT_CHANGES
    304         // "@self" marks that the view value itself has changed, and "@identity" marks that the
    305         // identity of the view has changed (that is, that the persistent data associated with
    306         // the view has been recycled for a new instance of the same type)
    307         if #available(iOS 17.1, *) {
    308             // logs at INFO level, “com.apple.SwiftUI” subsystem, category “Changed Body Properties”
    309             let _ = Self._logChanges()
    310         } else {
    311             let _ = Self._printChanges()
    312         }
    313 #endif
    314         ZStack(alignment: .bottom) {
    315             tabContent              // incl. the (transparent) SwiftUI tabBar
    316             if #unavailable(iOS 26.0) {
    317                 tabBarView()
    318             }
    319         } // ZStack
    320         .frame(maxWidth: .infinity, maxHeight: .infinity)
    321         .onNotification(.SendAction)    { notification in
    322             if let actionType = notification.userInfo?[NOTIFICATIONANIMATION] as? ActionType {
    323                 if actionType.animationDisabled {
    324                     var transaction = Transaction()
    325                     transaction.disablesAnimations = true
    326                     withTransaction(transaction) {
    327                         triggerAction(1)
    328                     }
    329                 } else { triggerAction(1) }
    330             } else { triggerAction(1) }
    331         }
    332         .onNotification(.RequestAction) { triggerAction(2) }
    333         .onNotification(.DepositAction) { triggerAction(3) }
    334         .onNotification(.WithdrawAction){ triggerAction(4) }
    335         .onNotification(.SettingsAction){ triggerAction(-1) }
    336         .onNotification(.KYCrequired) { notification in
    337             // show an alert with the KYC link (button) which opens in Safari
    338             if let transition = notification.userInfo?[TRANSACTIONTRANSITION] as? TransactionTransition {
    339                 if let kycString = transition.experimentalUserData {
    340                     if let urlForKYC = URL(string: kycString) {
    341                         logger.log(".onNotification(.KYCrequired): \(kycString)")
    342                         kycURI = urlForKYC
    343                         showKycAlert = true
    344                     }
    345                 } else {
    346                     // TODO: no KYC URI
    347                 }
    348             }
    349         }
    350         .onNotification(.ShareAction){ notification in
    351             if let actionData = notification.userInfo?[NOTIFICATIONSHARE] as? ShareType {
    352                 let textToShare = actionData.textToShare
    353                 let image = actionData.image
    354                 ShareSheet.shareSheet(textToShare: textToShare, image: image)
    355             }
    356         }
    357         .alert("You need to pass a legitimization procedure.",
    358                isPresented: $showKycAlert,
    359                actions: {   openKycButton
    360                             dismissAlertButton },
    361                message: {   Text("Tap the button to go to the legitimization website.") })
    362         .onNotification(.BalanceChange) { notification in
    363             logger.info(".onNotification(.BalanceChange) ==> reload balances")
    364 //            if let date = notification.userInfo?[NOTIFICATIONTIME] as? Date {
    365 //
    366 //            }
    367             shouldReloadBalances += 1
    368         }
    369         .onNotification(.TransactionDeleted) { notification in
    370             logger.info(".onNotification(.TransactionDeleted) ==> reload balances")
    371             shouldReloadTransactions += 1
    372         }
    373         .onNotification(.TransactionExpired) { notification in
    374             logger.info(".onNotification(.TransactionExpired) ==> reload balances")
    375             shouldReloadTransactions += 1
    376         }
    377         .onNotification(.TransactionScanned) {
    378             shouldReloadTransactions += 1
    379         }
    380         .onNotification(.TransactionDone) {
    381             shouldReloadTransactions += 1
    382 //            selectedTab = .balances       // automatically switch to Balances
    383         }
    384         .onNotification(.TransactionError) { notification in
    385 //          TODO:      reload pending ??
    386         }
    387         .onNotification(.GeneralError) { notification in
    388             if let error = notification.userInfo?[NOTIFICATIONERROR] as? Error {
    389                 model.setError(error)
    390                 controller.playSound(0)
    391             }
    392         }
    393         .task(id: shouldReloadBalances) {   // runs once at launch, then on each onNotification(.BalanceChange)
    394 //            symLog.log(".task shouldReloadBalances \(shouldReloadBalances)")
    395             await controller.loadBalances(stack.push("refreshing balances"))
    396             await controller.loadDiscounts(stack.push("refreshing discounts"))
    397             await controller.loadSubscriptions(stack.push("refreshing passes"))
    398         } // task
    399     } // body
    400 } // Content
    401