TransactionSummaryList.swift (21855B)
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 */ 8 import SwiftUI 9 import taler_swift 10 import SymLog 11 12 extension TalerTransaction { // for Dummys 13 init(dummyCurrency: String) { 14 let amount = Amount.zero(currency: dummyCurrency) 15 let now = Timestamp.now() 16 let common = TransactionCommon(type: .dummy, 17 transactionId: EMPTYSTRING, 18 timestamp: now, 19 scopes: [], 20 txState: TransactionState(major: .pending), 21 txActions: [], 22 amountRaw: amount, 23 amountEffective: amount) 24 self = .dummy(DummyTransaction(common: common)) 25 } 26 } 27 // MARK: - 28 struct TransactionSummaryList: View { 29 private let symLog = SymLogV(0) 30 let stack: CallStack 31 let transactionId: String 32 @Binding var talerTX: TalerTransaction 33 let navTitle: String? 34 let hasDone: Bool // false: just old tx, true: the conclusion of an transaction 35 let showDone: TalerButtonStyleType? 36 let url: URL? // the scanned talerURL from PaymentView 37 let withActions: Bool 38 39 @EnvironmentObject private var controller: Controller 40 @EnvironmentObject private var model: WalletModel 41 @Environment(\.colorScheme) private var colorScheme 42 @Environment(\.colorSchemeContrast) private var colorSchemeContrast 43 @Environment(\.dismiss) var dismiss // call dismiss() to pop back 44 @AppStorage("minimalistic") var minimalistic: Bool = false 45 @AppStorage("myListStyle") var myListStyle: MyListStyle = .automatic 46 @AppStorage("preferredColorScheme") var preferredColorScheme: Int = 0 47 #if DEBUG 48 @AppStorage("developerMode") var developerMode: Bool = true 49 #else 50 @AppStorage("developerMode") var developerMode: Bool = false 51 #endif 52 53 @State private var currencyInfo: CurrencyInfo = CurrencyInfo.zero(UNKNOWN) 54 @State private var ignoreThis: Bool = false 55 @State private var didDelete: Bool = false 56 @State var jsonTransaction: String = EMPTYSTRING 57 @State var viewId = UUID() 58 @State private var selectedChoice: Int? = nil 59 @State private var effective: Amount? = nil 60 @State private var scope: ScopeInfo? = nil 61 @State private var payNow: Bool = false 62 @Namespace var topID 63 64 func loadTransaction() async { 65 if let reloadedTransaction = try? await model.getTransactionById(transactionId, 66 includeContractTerms: true, viewHandles: false) { 67 symLog.log("reloaded \(reloadedTransaction.localizedType): \(reloadedTransaction.common.txState.major)") 68 withAnimation { 69 talerTX = reloadedTransaction; 70 scope = reloadedTransaction.common.scopes.first 71 viewId = UUID() // redraw 72 } 73 if developerMode { 74 if let json = try? await model.jsonTransactionById(transactionId, 75 includeContractTerms: true, viewHandles: false) { 76 jsonTransaction = json 77 } else { 78 jsonTransaction = EMPTYSTRING 79 } 80 } 81 } else { 82 withAnimation{ talerTX = TalerTransaction(dummyCurrency: DEMOCURRENCY); viewId = UUID() } 83 jsonTransaction = EMPTYSTRING 84 } 85 } 86 87 private func payTransaction() async { 88 if let confirmPayResult = try? await model.confirmPay(transactionId, 89 choiceIndex: selectedChoice) { 90 // symLog.log(confirmPayResult as Any) 91 if confirmPayResult.type == "done" { 92 if let url { 93 controller.removeURL(url) 94 } 95 } else { 96 if let url { 97 controller.removeURL(url) // TODO: pending might fail - in which case we might want to try again 98 } 99 } 100 } 101 } 102 103 @MainActor 104 @discardableResult 105 func checkDismiss(_ notification: Notification, _ logStr: String = EMPTYSTRING) -> Bool { 106 if hasDone { 107 if let transition = notification.userInfo?[TRANSACTIONTRANSITION] as? TransactionTransition { 108 if transition.transactionId == talerTX.common.transactionId { // is the transition for THIS transaction? 109 symLog.log(logStr) 110 if talerTX.common.type.isPayment { 111 checkReload(notification, logStr) 112 } else { 113 dismissTop(stack.push()) // if this view is in a sheet then dissmiss the sheet 114 return true 115 } 116 } 117 } 118 } else { // no sheet but the details view -> reload 119 checkReload(notification, logStr) 120 } 121 return false 122 } 123 124 @MainActor 125 private func dismiss(_ stack: CallStack) { 126 if hasDone { // if this view is in a sheet then dissmiss the whole sheet 127 dismissTop(stack.push()) 128 } else { // on a NavigationStack just pop 129 dismiss() 130 } 131 } 132 133 func checkReload(_ notification: Notification, _ logStr: String = EMPTYSTRING) { 134 if let transition = notification.userInfo?[TRANSACTIONTRANSITION] as? TransactionTransition { 135 if transition.transactionId == transactionId { // is the transition for THIS transaction? 136 let newMajor = transition.newTxState.major 137 Task { // runs on MainActor 138 // flush the screen first, then reload 139 withAnimation { talerTX = TalerTransaction(dummyCurrency: DEMOCURRENCY); viewId = UUID() } 140 symLog.log("newState: \(newMajor), reloading transaction") 141 if newMajor != .none { // don't reload after delete 142 await loadTransaction() 143 } 144 } 145 } 146 } else { // Yikes - should never happen 147 // TODO: logger.warning("Can't get notification.userInfo as TransactionTransition") 148 symLog.log(notification.userInfo as Any) 149 } 150 } 151 152 func localizedState(_ txState: TransactionState) -> String { 153 let major = txState.major 154 if major != .failed { 155 if let minorState = txState.minor { 156 if developerMode { return minorState.localizedDbgState } 157 // if talerTX.isPayment { 158 // return String(localized: "Payment", comment: "TxMajorState heading") 159 // } 160 return minorState.localizedState ?? major.localizedState 161 } 162 } 163 return major.localizedState 164 } 165 166 @ViewBuilder 167 func dateAndStatus(_ common: TransactionCommon) -> some View { 168 let (dateString, date) = TalerDater.dateString(common.timestamp, minimalistic) 169 let a11yDate = TalerDater.accessibilityDate(date) ?? dateString 170 Text(dateString) 171 .talerFont(.body) 172 .accessibilityLabel(a11yDate) 173 .foregroundColor(WalletColors().secondary(colorScheme, colorSchemeContrast)) 174 .id(topID) 175 let state = localizedState(common.txState) 176 let statusT = Text(state) 177 .multilineTextAlignment(.trailing) 178 let imageT = Text(common.type.icon()) 179 .accessibilityHidden(true) 180 HStack(alignment: .center, spacing: HSPACING) { 181 imageT 182 Spacer(minLength: 0) 183 statusT 184 } // TODO: a11y for tx icon 185 if developerMode { 186 if !jsonTransaction.isEmpty { 187 CopyButton(jsonTransaction, title: "Copy JSON") 188 } 189 } 190 } 191 192 @ViewBuilder 193 func suspendResume(_ common: TransactionCommon) -> some View { 194 if talerTX.isSuspendable { 195 TransactionButton(transactionId: common.transactionId, 196 command: .suspend, 197 warning: nil, 198 didExecute: $ignoreThis, 199 action: model.suspendTransaction) 200 .listRowSeparator(.hidden) 201 } 202 if talerTX.isResumable { 203 TransactionButton(transactionId: common.transactionId, 204 command: .resume, 205 warning: nil, 206 didExecute: $ignoreThis, 207 action: model.resumeTransaction) 208 .listRowSeparator(.hidden) 209 } 210 } 211 212 @ViewBuilder 213 func abortFailDelete(_ common: TransactionCommon) -> some View { 214 if talerTX.isAbortable { 215 let warning = String(localized: "Are you sure you want to abort this transaction?") 216 TransactionButton(transactionId: common.transactionId, 217 command: .abort, 218 warning: warning, 219 didExecute: $ignoreThis, 220 action: model.abortTransaction) 221 } // Abort button 222 if talerTX.isFailable { 223 let warning = String(localized: "Are you sure you want to abandon this transaction?") 224 TransactionButton(transactionId: common.transactionId, 225 command: .fail, 226 warning: warning, 227 didExecute: $ignoreThis, 228 action: model.failTransaction) 229 } // Fail button 230 if talerTX.isDeleteable { 231 let warning = String(localized: "Are you sure you want to delete this transaction?") 232 TransactionButton(transactionId: common.transactionId, 233 command: .delete, 234 warning: warning, 235 didExecute: $didDelete, 236 action: model.deleteTransaction) 237 .onChange(of: didDelete) { wasDeleted in 238 if wasDeleted { 239 symLog.log("wasDeleted -> dismiss view") 240 dismiss(stack) 241 } 242 } 243 } // Delete button 244 } 245 246 var body: some View { 247 #if PRINT_CHANGES 248 let _ = Self._printChanges() 249 let _ = symLog.vlog() // just to get the # to compare it with .onAppear & onDisappear 250 #endif 251 let common = talerTX.common 252 // let scope = common.scopes.first // might be nil if scopes == [] 253 let locale = TalerDater.shared.locale 254 let isPaying = talerTX.isPayment && talerTX.isDialog 255 let navTitle2 = talerTX.isDone ? talerTX.localizedTypePast 256 : isPaying ? String(localized: "Confirm Payment", comment:"pay merchant navTitle") 257 : talerTX.localizedType 258 Group { 259 if common.type != .dummy && transactionId == common.transactionId { 260 let list = List { 261 if developerMode && withActions { suspendResume(common) } 262 if !isPaying { 263 Section { 264 VStack(alignment: .leading) { 265 dateAndStatus(common) 266 .listRowSeparator(.hidden) 267 .talerFont(.title) 268 }.overlay { 269 if common.isWorking { 270 RotatingTaler(size: 80, progress: true, once: false, 271 rotationEnabled: Binding.constant(true)) 272 } 273 } 274 } 275 } 276 TransactionTypeDetail(stack: stack.push(), 277 transaction: $talerTX, 278 payNow: $payNow, 279 selectedChoice: $selectedChoice, 280 scope: $scope, 281 effective: $effective, 282 hasDone: hasDone) 283 284 // TODO: Retry Countdown, Retry Now button 285 // if talerTX.isRetryable, let retryAction { 286 // TransactionButton(transactionId: common.transactionId, command: .retry, 287 // warning: nil, action: abortAction) 288 // } // Retry button 289 if withActions { abortFailDelete(common) } 290 }.id(viewId) // change viewId to enforce a draw update 291 .listStyle(myListStyle.style).anyView 292 .background(FullBackground()) 293 .navigationBarBackButtonHidden(hasDone) 294 .interactiveDismissDisabled(hasDone) // can only use "Done" button to dismiss 295 .safeAreaInset(edge: .bottom) { 296 if isPaying, case .payment(let paymentTransaction) = talerTX { 297 let details = paymentTransaction.details 298 if let effective, let url, let terms = details.contractTerms { 299 let formatted = effective.formatted(currencyInfo) 300 PaySafeArea(symLog: symLog, 301 stack: stack.push(), 302 terms: terms, 303 amountString: formatted.0, 304 amountA11y: formatted.1, 305 payNow: $payNow) 306 } else { 307 Button("Cancel") { dismissTop(stack.push()) } 308 .buttonStyle(TalerButtonStyle(type: .bordered)) 309 .padding(.horizontal) 310 } // Cancel 311 } else if let showDone { 312 Button("Done") { dismissTop(stack.push()) } 313 .buttonStyle(TalerButtonStyle(type: showDone)) 314 .padding(.horizontal) 315 } 316 } 317 .onChange(of: payNow) { payNow2 in 318 if payNow2 { 319 Task { 320 payNow = false 321 await payTransaction() 322 } 323 } 324 } 325 .onNotification(.TransactionExpired) { notification in 326 // TODO: Alert user that this tx just expired 327 if checkDismiss(notification, "newTxState.major == expired => dismiss sheet") { 328 // TODO: logger.info("newTxState.major == expired => dismiss sheet") 329 } 330 } 331 .onNotification(.TransactionDone) { notification in 332 checkDismiss(notification, "newTxState.major == done => dismiss sheet") 333 } 334 .onNotification(.DismissSheet) { notification in 335 checkDismiss(notification, "exchangeWaitReserve or withdrawCoins => dismiss sheet") 336 } 337 .onNotification(.PendingReady) { notification in 338 checkReload(notification, "pending ready ==> reload for talerURI") 339 } 340 .onNotification(.TransactionStateTransition) { notification in 341 if !didDelete { 342 checkReload(notification, "some transition ==> reload") 343 } 344 } 345 .navigationTitle(navTitle ?? navTitle2) 346 347 if #available(iOS 17.0, *) { 348 list 349 .scrollContentBackground(preferredColorScheme == 3 ? .hidden : .visible) 350 .toolbarTitleDisplayMode(.inlineLarge) 351 } else { 352 list 353 } 354 } else { 355 Color.clear 356 .frame(maxWidth: .infinity, maxHeight: .infinity) 357 .task { 358 symLog.log("task - load transaction") 359 await loadTransaction() 360 } 361 } // else 362 } // Group 363 .onChange(of: scope) { newVal in 364 if let newVal { 365 currencyInfo = controller.info(for: newVal) ?? CurrencyInfo.zero(UNKNOWN) 366 } 367 } 368 .onAppear { 369 symLog.log("onAppear") 370 DebugViewC.shared.setViewID(VIEW_TRANSACTIONSUMMARY, stack: stack.push()) 371 } 372 .onDisappear { 373 symLog.log("onDisappear") 374 } 375 } 376 } // TransactionSummaryList 377 // MARK: - 378 struct KYCbutton: View { 379 let kycUrl: String? 380 381 var body: some View { 382 if let kycUrl { 383 if let destination = URL(string: kycUrl) { 384 LinkButton(destination: destination, 385 hintTitle: String(localized: "You need to pass a legitimization procedure.", comment: "KYC"), 386 buttonTitle: String(localized: "Open legitimization website", comment: "KYC"), 387 a11yHint: String(localized: "Will go to legitimization website to permit this withdrawal.", comment: "a11y"), 388 badge: NEEDS_KYC) 389 } 390 } 391 } 392 } 393 // MARK: - 394 struct PendingWithdrawalDetails: View { 395 let stack: CallStack 396 @Binding var transaction: TalerTransaction 397 let details: WithdrawalTransactionDetails 398 399 var body: some View { 400 let common = transaction.common 401 if transaction.isPendingKYC { 402 if let kycUrl = common.kycUrl { 403 KYCbutton(kycUrl: common.kycUrl) 404 } else { 405 Text("Legitimization procedure required", comment: "KYC") 406 } 407 } 408 let withdrawalDetails = details.withdrawalDetails 409 switch withdrawalDetails.type { 410 case .manual: // "Make a wire transfer of \(amount) to" 411 ManualDetailsV(stack: stack.push(), common: common, details: withdrawalDetails) 412 413 case .bankIntegrated: // "Authorize now" (with bank) 414 if !transaction.isPendingKYC { // cannot authorize if KYC is needed first 415 let confirmed = withdrawalDetails.confirmed ?? false 416 if !confirmed { 417 if let confirmationUrl = withdrawalDetails.bankConfirmationUrl { 418 if let destination = URL(string: confirmationUrl) { 419 LinkButton(destination: destination, 420 hintTitle: String(localized: "The bank is waiting for your authorization."), 421 buttonTitle: String(localized: "Authorize now"), 422 a11yHint: String(localized: "Will go to bank website to authorize this withdrawal.", comment: "a11y"), 423 badge: CONFIRM_BANK) 424 } } } } 425 @unknown default: 426 ErrorView(stack.push(), 427 title: "Unknown withdrawal type", // should not happen, so no L10N 428 message: withdrawalDetails.toJSON(), 429 copyable: true) 430 } // switch 431 } 432 } 433 // MARK: - 434 struct QRCodeDetails: View { 435 var transaction : TalerTransaction 436 var body: some View { 437 let details = transaction.detailsToShow() 438 let keys = details.keys 439 if keys.contains(TALERURI) { 440 if let talerURI = details[TALERURI] { 441 if talerURI.count > 10 { 442 QRCodeDetailView(talerURI: talerURI, 443 talerCopyShare: talerURI, 444 incoming: transaction.isP2pIncoming, 445 amount: transaction.common.amountRaw, 446 scope: transaction.common.scopes.first) 447 // scopes shouldn't (- but might) be nil! 448 } 449 } 450 } else if keys.contains(EXCHANGEBASEURL) { 451 if let baseURL = details[EXCHANGEBASEURL] { 452 Text("from \(baseURL.trimURL)", comment: "baseURL") 453 .talerFont(.title2) 454 .padding(.bottom) 455 } 456 } 457 } 458 } 459 // MARK: - 460 #if DEBUG 461 //struct TransactionSummary_Previews: PreviewProvider { 462 // static func deleteTransactionDummy(transactionId: String) async throws {} 463 // static func doneActionDummy() {} 464 // static var withdrawal = TalerTransaction(incoming: true, 465 // pending: true, 466 // id: "some withdrawal ID", 467 // time: Timestamp(from: 1_666_000_000_000)) 468 // static var payment = TalerTransaction(incoming: false, 469 // pending: false, 470 // id: "some payment ID", 471 // time: Timestamp(from: 1_666_666_000_000)) 472 // static func reloadActionDummy(transactionId: String) async -> TalerTransaction { return withdrawal } 473 // static var previews: some View { 474 // Group { 475 // TransactionSummaryV(transaction: withdrawal, reloadAction: reloadActionDummy, doneAction: doneActionDummy) 476 // TransactionSummaryV(transaction: payment, reloadAction: reloadActionDummy) 477 // } 478 // } 479 //} 480 #endif