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