taler-ios

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

WalletMain.swift (19365B)


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