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