WalletMain.swift (17469B)
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(stack: stack.push(), 198 tab: TalerTab.actions, 199 onTap: onActionTab, 200 onDrag: onActionDrag) 201 .accessibilitySortPriority(2) // Reads second 202 .matchedTransitionSource(id: "unique_transition_id", in: wrapper.namespace) 203 // .navigationTransition(.zoom(sourceID: "unique_transition_id", in: wrapper.namespace)) 204 } 205 .padding(.horizontal) 206 #if OIM 207 if !controller.oimModeActive { // hide for OIM 208 buttons 209 } 210 #else 211 buttons 212 #endif 213 } // iOS 26 214 }.background(balanceActions) 215 216 }.navigationViewStyle(.stack) 217 .accessibilitySortPriority(3) // Reads first 218 219 if #available(iOS 26.0, *) { 220 // no TabView, just a single NavigationView, containing the BalancesListView 221 // with floating Liquid Glass Actions button 222 return balancesStack.id(viewState.rootViewId) // change rootViewId to trigger popToRootView behaviour 223 } else { 224 // iOS 15..18: TabView with 3 tabs, middle is Actions 225 let settingsStack = NavigationView { 226 SettingsView(stack: stack.push(), 227 navTitle: settingsTitle) 228 .background(settingsActions) 229 }.navigationViewStyle(.stack) 230 /// invisible tabItems, used for a11y 231 let a11yBalanceTab = TalerTab.balances.label 232 .accessibilityLabel(balancesTitle) 233 .labelStyle(.titleOnly) 234 let a11yActionsTab = Label(TalerTab.actions) 235 .accessibilityLabel(actionTitle) 236 .labelStyle(.titleOnly) 237 let a11ySettingsTab = Label(TalerTab.settings) 238 .accessibilityLabel(settingsTitle) 239 .labelStyle(.titleOnly) 240 return TabView(selection: tabSelection()) { 241 balancesStack.id(viewState.rootViewId) // change rootViewId to trigger popToRootView behaviour 242 .tag(TalerTab.balances) 243 .tabItem { a11yBalanceTab } 244 Color.clear // can't use EmptyView: VoiceOver wouldn't have the Actions tab 245 .tag(TalerTab.actions) 246 .tabItem { a11yActionsTab } 247 settingsStack.id(viewState2.rootViewId) // change rootViewId to trigger popToRootView behaviour 248 .tag(TalerTab.settings) 249 .tabItem { a11ySettingsTab } 250 } // TabView 251 } 252 } 253 254 func onActionTab() { 255 logger.log("onActionTab: showActionSheet = true") 256 controller.removeURLs(after: 60*60) // TODO: specify time after which scanned URLs are deleted 257 showActionSheet = true 258 } 259 260 func onActionDrag(_ stack: CallStack) { 261 if !showScanner { // gets called multiple times - log only once 262 #if DEBUG 263 if let caller = stack.peek() { 264 logger.log("showScanner = true (onActionDrag: \(caller.file):\(caller.function))") 265 } 266 #endif 267 showScanner = true 268 } 269 } 270 271 func tabBarView() -> some View { 272 // iOS 15..18: custom tabBar (with Actions button) is rendered on top of the TabView, 273 // and overlays the native iOS tabBar (which is still used for VoiceOver) 274 TabBarView(stack: stack.push(), 275 selection: tabSelection(), 276 hidden: $tabBarModel.tabBarHidden, 277 onActionTab: onActionTab, 278 onActionDrag: onActionDrag) 279 .ignoresSafeArea(.keyboard, edges: .bottom) 280 .accessibilityHidden(true) // for a11y we use the original tabBar, not our custom one 281 } 282 283 var body: some View { 284 #if PRINT_CHANGES 285 // "@self" marks that the view value itself has changed, and "@identity" marks that the 286 // identity of the view has changed (that is, that the persistent data associated with 287 // the view has been recycled for a new instance of the same type) 288 if #available(iOS 17.1, *) { 289 // logs at INFO level, “com.apple.SwiftUI” subsystem, category “Changed Body Properties” 290 let _ = Self._logChanges() 291 } else { 292 let _ = Self._printChanges() 293 } 294 #endif 295 ZStack(alignment: .bottom) { 296 tabContent // incl. the (transparent) SwiftUI tabBar 297 if #unavailable(iOS 26.0) { 298 tabBarView() 299 } 300 } // ZStack 301 .frame(maxWidth: .infinity, maxHeight: .infinity) 302 .onNotification(.SendAction) { notification in 303 if let actionType = notification.userInfo?[NOTIFICATIONANIMATION] as? ActionType { 304 if actionType.animationDisabled { 305 var transaction = Transaction() 306 transaction.disablesAnimations = true 307 withTransaction(transaction) { 308 triggerAction(1) 309 } 310 } else { triggerAction(1) } 311 } else { triggerAction(1) } 312 } 313 .onNotification(.RequestAction) { triggerAction(2) } 314 .onNotification(.DepositAction) { triggerAction(3) } 315 .onNotification(.WithdrawAction){ triggerAction(4) } 316 .onNotification(.SettingsAction){ triggerAction(-1) } 317 .onNotification(.KYCrequired) { notification in 318 // show an alert with the KYC link (button) which opens in Safari 319 if let transition = notification.userInfo?[TRANSACTIONTRANSITION] as? TransactionTransition { 320 if let kycString = transition.experimentalUserData { 321 if let urlForKYC = URL(string: kycString) { 322 logger.log(".onNotification(.KYCrequired): \(kycString)") 323 kycURI = urlForKYC 324 showKycAlert = true 325 } 326 } else { 327 // TODO: no KYC URI 328 } 329 } 330 } 331 .onNotification(.ShareAction){ notification in 332 if let actionData = notification.userInfo?[NOTIFICATIONSHARE] as? ShareType { 333 let textToShare = actionData.textToShare 334 let image = actionData.image 335 ShareSheet.shareSheet(textToShare: textToShare, image: image) 336 } 337 } 338 .alert("You need to pass a legitimization procedure.", 339 isPresented: $showKycAlert, 340 actions: { openKycButton 341 dismissAlertButton }, 342 message: { Text("Tap the button to go to the legitimization website.") }) 343 .onNotification(.BalanceChange) { notification in 344 logger.info(".onNotification(.BalanceChange) ==> reload balances") 345 // if let date = notification.userInfo?[NOTIFICATIONTIME] as? Date { 346 // 347 // } 348 shouldReloadBalances += 1 349 } 350 .onNotification(.TransactionExpired) { notification in 351 logger.info(".onNotification(.TransactionExpired) ==> reload balances") 352 shouldReloadTransactions += 1 353 // shouldReloadPending += 1 354 } 355 .onNotification(.TransactionScanned) { 356 shouldReloadTransactions += 1 357 } 358 .onNotification(.TransactionDone) { 359 shouldReloadTransactions += 1 360 // shouldReloadPending += 1 361 // selectedTab = .balances // automatically switch to Balances 362 } 363 .onNotification(.TransactionError) { notification in 364 // shouldReloadPending += 1 365 } 366 .onNotification(.GeneralError) { notification in 367 if let error = notification.userInfo?[NOTIFICATIONERROR] as? Error { 368 model.setError(error) 369 controller.playSound(0) 370 } 371 } 372 .task(id: shouldReloadBalances) { // runs once at launch, then on each onNotification(.BalanceChange) 373 // symLog.log(".task shouldReloadBalances \(shouldReloadBalances)") 374 await controller.loadBalances(stack.push("refreshing balances"), model) 375 await controller.loadDiscounts(stack.push("refreshing discounts"), model) 376 await controller.loadSubscriptions(stack.push("refreshing passes"), model) 377 } // task 378 } // body 379 } // Content 380