taler-ios

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

WalletMain.swift (17181B)


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