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