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