taler-ios

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

WalletMain.swift (16975B)


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