TransactionSummaryList.swift (25499B)
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 // true if called from pending or history, or refund 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 private var jsonTransaction: String = EMPTYSTRING 57 @State private var viewId = UUID() 58 @State private var selectedChoice: Int? = nil 59 @State private var effective: Amount? = nil 60 @State private var paymentPossible: Bool? = nil 61 @State private var scope: ScopeInfo? = nil 62 @State private var isFinished: Bool? = false 63 @State private var payNow: Bool = false 64 @State private var isWorking: Bool = false 65 @Namespace var topID 66 67 func loadTransaction() async { 68 if let reloadedTransaction = try? await model.getTransactionById(transactionId, 69 includeContractTerms: true, viewHandles: false) { 70 symLog.log("reloaded \(reloadedTransaction.localizedType): \(reloadedTransaction.common.txState.major)") 71 withAnimation { 72 talerTX = reloadedTransaction; 73 isWorking = reloadedTransaction.common.isWorking 74 #if DEBUG 75 // controller.playSound(1104, useSystem: true) 76 #endif 77 scope = reloadedTransaction.common.scopes.first 78 } 79 if developerMode { 80 if let json = try? await model.jsonTransactionById(transactionId, 81 includeContractTerms: true, viewHandles: false) { 82 jsonTransaction = json 83 } else { 84 jsonTransaction = EMPTYSTRING 85 } 86 } 87 } else { 88 withAnimation{ 89 talerTX = TalerTransaction(dummyCurrency: DEMOCURRENCY); 90 isWorking = false 91 viewId = UUID() 92 } 93 jsonTransaction = EMPTYSTRING 94 } 95 } 96 97 private func payTransaction() async { 98 if let confirmPayResult = try? await model.confirmPay(transactionId, 99 choiceIndex: selectedChoice) { 100 // symLog.log(confirmPayResult as Any) 101 if confirmPayResult.type == "done" { 102 if let url { 103 controller.removeURL(url) 104 } 105 } else { 106 if let url { 107 controller.removeURL(url) // TODO: pending might fail - in which case we might want to try again 108 } 109 } 110 let txId = confirmPayResult.transactionId 111 if let finalResult = try? await model.waitTransactionState(txId, token: txId, major: .done) { 112 if let matched = finalResult.matched { 113 print(matched) 114 } 115 } 116 } 117 } 118 119 @MainActor 120 @discardableResult 121 func checkDismiss(_ notification: Notification, _ logStr: String = EMPTYSTRING) -> Bool { 122 if let transition = notification.userInfo?[TRANSACTIONTRANSITION] as? TransactionTransition { 123 if transition.transactionId == talerTX.common.transactionId { // is the transition for THIS transaction? 124 if transition.newTxState.major == .deleted { 125 dismissTop(stack.push()) // dissmiss the sheet / the navigation stack 126 return true 127 } 128 if hasDone { 129 symLog.log(logStr) 130 if talerTX.common.type.isPayment { 131 checkReload(stack.push("checkDismiss1"), notification, logStr) 132 } else { 133 dismissTop(stack.push()) // if this view is in a sheet then dissmiss the sheet 134 return true 135 } 136 } else { // no sheet but the details view -> reload 137 checkReload(stack.push("checkDismiss2"), notification, logStr) 138 } 139 } 140 } 141 return false 142 } 143 144 @MainActor 145 private func dismiss(_ stack: CallStack) { 146 if hasDone { // if this view is in a sheet then dissmiss the whole sheet 147 dismissTop(stack.push()) 148 } else { // on a NavigationStack just pop 149 dismiss() 150 } 151 } 152 153 func checkReload(_ stack: CallStack, _ notification: Notification, _ logStr: String = EMPTYSTRING) { 154 if let transition: TransactionTransition = notification.userInfo?[TRANSACTIONTRANSITION] as? TransactionTransition { 155 if transition.transactionId == transactionId { // is the transition for THIS transaction? 156 let newMajor = transition.newTxState.major 157 let message = stack.peek()?.message ?? "?" 158 if newMajor == .deleted { 159 symLog.log("newState: \(newMajor), show 'deleted', for: \(message)") 160 let error = transition.errorInfo ?? 161 TalerErrorDetail(code: 7049, // WALLET_MERCHANT_ORDER_NOT_FOUND 162 when: .now(), 163 hint: "Transaction has been deleted") 164 model.setError(WalletBackendError.walletCoreError(error)) 165 } else { 166 Task { // runs on MainActor 167 // flush the screen first, then reload 168 withAnimation { 169 talerTX = TalerTransaction(dummyCurrency: DEMOCURRENCY); 170 isWorking = false 171 viewId = UUID() 172 } 173 if newMajor != .none { // don't reload after delete 174 symLog.log("newState: \(newMajor), reloading transaction, for: \(message)") 175 await loadTransaction() 176 } 177 } 178 } 179 } 180 } else { // Yikes - should never happen 181 // TODO: logger.warning("Can't get notification.userInfo as TransactionTransition") 182 symLog.log(notification.userInfo as Any) 183 } 184 } 185 186 func localizedState(_ txState: TransactionState) -> String { 187 let major = txState.major 188 if major != .failed { 189 if let minorState = txState.minor { 190 if developerMode { return minorState.localizedDbgState } 191 // if talerTX.isPayment { 192 // return String(localized: "Payment", comment: "TxMajorState heading") 193 // } 194 return minorState.localizedState ?? major.localizedState 195 } 196 } 197 return major.localizedState 198 } 199 200 @ViewBuilder 201 func dateAndStatus(_ common: TransactionCommon) -> some View { 202 let (dateString, date) = TalerDater.dateString(common.timestamp, minimalistic) 203 let a11yDate = TalerDater.accessibilityDate(date) ?? dateString 204 Text(dateString) 205 .talerFont(.body) 206 .accessibilityLabel(a11yDate) 207 .foregroundColor(WalletColors().secondary2(colorScheme, colorSchemeContrast)) 208 .id(topID) 209 let state = localizedState(common.txState) 210 let statusT = Text(state) 211 .multilineTextAlignment(.trailing) 212 let imageT = Text(common.type.icon()) 213 .accessibilityHidden(true) 214 HStack(alignment: .center, spacing: HSPACING) { 215 imageT 216 Spacer(minLength: 0) 217 statusT 218 } // TODO: a11y for tx icon 219 if developerMode { 220 if !jsonTransaction.isEmpty { 221 CopyButton(jsonTransaction, title: "Copy JSON") 222 } 223 } 224 } 225 226 @ViewBuilder 227 func suspendResume(_ common: TransactionCommon) -> some View { 228 if talerTX.isSuspendable { 229 TransactionButton(transactionId: common.transactionId, 230 command: .suspend, 231 warning: nil, 232 didExecute: $ignoreThis, 233 action: model.suspendTransaction) 234 .listRowSeparator(.hidden) 235 } 236 if talerTX.isResumable { 237 TransactionButton(transactionId: common.transactionId, 238 command: .resume, 239 warning: nil, 240 didExecute: $ignoreThis, 241 action: model.resumeTransaction) 242 .listRowSeparator(.hidden) 243 } 244 } 245 246 @ViewBuilder 247 func abortFailDelete(_ common: TransactionCommon) -> some View { 248 if talerTX.isAbortable { 249 let warning = String(localized: "Are you sure you want to abort this transaction?") 250 TransactionButton(transactionId: common.transactionId, 251 command: .abort, 252 warning: warning, 253 didExecute: $ignoreThis, 254 action: model.abortTransaction) 255 } // Abort button 256 if talerTX.isFailable { 257 let warning = String(localized: "Are you sure you want to abandon this transaction?") 258 TransactionButton(transactionId: common.transactionId, 259 command: .fail, 260 warning: warning, 261 didExecute: $ignoreThis, 262 action: model.failTransaction) 263 } // Fail button 264 if talerTX.isDeleteable { 265 let warning = String(localized: "Are you sure you want to delete this transaction?") 266 TransactionButton(transactionId: common.transactionId, 267 command: .delete, 268 warning: warning, 269 didExecute: $didDelete, 270 action: model.deleteTransaction) 271 .onChange(of: didDelete) { wasDeleted in 272 if wasDeleted { 273 symLog.log("wasDeleted -> dismiss view") 274 dismiss(stack) 275 } 276 } 277 } // Delete button 278 } 279 280 var possibleStr: String { 281 if let possible = paymentPossible { 282 return possible ? String(localized: "Confirm Payment", comment:"pay merchant navTitle") 283 : String(localized: "Payment not possible", comment:"pay merchant navTitle") 284 } 285 return String(localized: "Payment", comment:"pay merchant navTitle") 286 } 287 var navTitleStr: String { 288 let isPaying = talerTX.isPayment && talerTX.isDialog 289 let isRepurchase = talerTX.common.isRepurchase 290 return talerTX.isDone ? talerTX.localizedTypePast 291 : isPaying ? possibleStr 292 : isRepurchase ? String(localized: "Already paid", comment:"repurchase merchant navTitle") 293 : talerTX.localizedType 294 } 295 var is26: Bool { 296 if #available(iOS 26.0, *) { true } else { false } 297 } 298 299 var body: some View { 300 #if PRINT_CHANGES 301 let _ = Self._printChanges() 302 let _ = symLog.vlog() // just to get the # to compare it with .onAppear & onDisappear 303 #endif 304 #if DEBUG 305 let background = developerMode ? Color.red : nil 306 #else 307 let background: Color? = nil 308 #endif 309 let common = talerTX.common 310 // let scope = common.scopes.first // might be nil if scopes == [] 311 let locale = TalerDater.shared.locale 312 let isPayDialog = talerTX.isPayment && talerTX.isDialog 313 Group { 314 if common.type != .dummy && transactionId == common.transactionId { 315 let list = List { 316 if developerMode && withActions { suspendResume(common) } 317 if !isPayDialog { 318 Section { 319 HStack { 320 VStack(alignment: .leading) { 321 dateAndStatus(common) 322 .listRowSeparator(.hidden) 323 .talerFont(.title) 324 } 325 if isWorking { 326 RotatingTaler(size: 80, progress: true, once: false, 327 background: background, 328 rotationEnabled: Binding.constant(true)) 329 .transition(.opacity.combined(with: .scale)) 330 } 331 } 332 } 333 } 334 if !controller.stalledConnection { 335 TransactionTypeDetail(stack: stack.push(), 336 transaction: $talerTX, 337 payNow: $payNow, 338 selectedChoice: $selectedChoice, 339 scope: $scope, 340 effective: $effective, // return amount for "Pay <amount> now" 341 possible: $paymentPossible, 342 hasDone: hasDone) 343 if withActions { abortFailDelete(common) } 344 } 345 }.id(viewId) // change viewId to enforce a draw update 346 .listStyle(myListStyle.style).anyView 347 .background(FullBackground()) 348 .navigationBarBackButtonHidden(hasDone) 349 .interactiveDismissDisabled(hasDone && !isPayDialog) // can only use "Done" button to dismiss 350 .safeAreaInset(edge: .bottom) { 351 let cancelButton = Button("Cancel") { dismissTop(stack.push()) } 352 .buttonStyle(TalerButtonStyle(type: .bordered)) 353 .padding(is26 ? .horizontal : .all) 354 let bottomButton = ZStack { 355 if isPayDialog, case .payment(let paymentTransaction) = talerTX { 356 let details = paymentTransaction.details 357 if let effective, let terms = details.contractTerms, isFinished != true { 358 let formatted = effective.formatted(currencyInfo) 359 PaySafeArea(symLog: symLog, 360 stack: stack.push(), 361 terms: terms, 362 amountString: formatted.0, 363 amountA11y: formatted.1, 364 isFinished: $isFinished, 365 payNow: $payNow) 366 } else { 367 cancelButton 368 } // Cancel 369 } else if let showDone { 370 Button("Done") { dismissTop(stack.push()) } 371 .buttonStyle(TalerButtonStyle(type: showDone)) 372 .padding(is26 ? .horizontal : .all) 373 } 374 } 375 if controller.stalledConnection { 376 VStack { 377 StalledView() 378 cancelButton 379 .padding(.bottom) 380 } 381 .background { 382 SelectedBackground(useColor: false) 383 } 384 .padding(is26 ? .horizontal : .all) 385 .transition(.move(edge: .bottom)) 386 } else { 387 bottomButton 388 .padding(is26 ? .horizontal : .all) 389 .transition(.scale.combined(with: .opacity)) 390 } 391 } 392 .navigationTitle(navTitle ?? navTitleStr) 393 .onChange(of: payNow) { payNow2 in 394 if payNow2 { 395 Task { 396 await payTransaction() 397 payNow = false // re-enable "Pay now" if this attempt did not lead to a state transition 398 } 399 } 400 } 401 .onNotification(.TransactionExpired) { notification in 402 // TODO: Alert user that this tx just expired 403 if checkDismiss(notification, "newTxState.major == expired => dismiss sheet") { 404 // TODO: logger.info("newTxState.major == expired => dismiss sheet") 405 } 406 } 407 .onNotification(.TransactionDone) { notification in 408 checkDismiss(notification, "newTxState.major == done => dismiss sheet") 409 } 410 .onNotification(.DismissSheet) { notification in 411 checkDismiss(notification, "exchangeWaitReserve or withdrawCoins => dismiss sheet") 412 } 413 .onNotification(.PendingReady) { notification in 414 checkReload(stack.push("pendingReady"), notification, "pending ready ==> reload for talerURI") 415 } 416 .onNotification(.TransactionStateTransition) { notification in 417 if !didDelete { 418 checkReload(stack.push("TransactionStateTransition"), notification, "some transition ==> reload") 419 } 420 } 421 422 if #available(iOS 17.0, *) { 423 list 424 .scrollContentBackground(preferredColorScheme == 3 ? .hidden : .visible) 425 .toolbarTitleDisplayMode(.inlineLarge) 426 } else { 427 list 428 } 429 } else { 430 Color.clear 431 .frame(maxWidth: .infinity, maxHeight: .infinity) 432 .task { 433 symLog.log("task - load transaction") 434 await loadTransaction() 435 } 436 } // else 437 } // Group 438 .onChange(of: scope) { newVal in 439 if let newVal { 440 currencyInfo = controller.info(for: newVal) ?? CurrencyInfo.zero(UNKNOWN) 441 } 442 } 443 .onAppear { 444 symLog.log("onAppear") 445 DebugViewC.shared.setViewID(VIEW_TRANSACTIONSUMMARY, stack: stack.push()) 446 } 447 .onDisappear { 448 symLog.log("onDisappear") 449 } 450 } 451 } // TransactionSummaryList 452 // MARK: - 453 struct KYCbutton: View { 454 let kycUrl: String? 455 456 var body: some View { 457 if let kycUrl { 458 if let destination = URL(string: kycUrl) { 459 LinkButton(destination: destination, 460 hintTitle: String(localized: "You need to pass a legitimization procedure.", comment: "KYC"), 461 buttonTitle: String(localized: "Open legitimization website", comment: "KYC"), 462 a11yHint: String(localized: "Will go to legitimization website to permit this withdrawal.", comment: "a11y"), 463 badge: NEEDS_KYC) 464 } 465 } 466 } 467 } 468 // MARK: - 469 struct PendingWithdrawalDetails: View { 470 let stack: CallStack 471 @Binding var transaction: TalerTransaction 472 let details: WithdrawalTransactionDetails 473 474 var body: some View { 475 let common = transaction.common 476 if transaction.isPendingKYC { 477 if let kycUrl = common.kycUrl { 478 KYCbutton(kycUrl: common.kycUrl) 479 } else { 480 Text("Legitimization procedure required", comment: "KYC") 481 } 482 } 483 let withdrawalDetails = details.withdrawalDetails 484 switch withdrawalDetails.type { 485 case .manual: // "Make a wire transfer of \(amount) to" 486 ManualDetailsV(stack: stack.push(), common: common, details: withdrawalDetails) 487 488 case .bankIntegrated: // "Authorize now" (with bank) 489 if !transaction.isPendingKYC { // cannot authorize if KYC is needed first 490 let confirmed = withdrawalDetails.confirmed ?? false 491 if !confirmed { 492 if let confirmationUrl = withdrawalDetails.bankConfirmationUrl { 493 if let destination = URL(string: confirmationUrl) { 494 LinkButton(destination: destination, 495 hintTitle: String(localized: "The bank is waiting for your authorization."), 496 buttonTitle: String(localized: "Authorize now"), 497 a11yHint: String(localized: "Will go to bank website to authorize this withdrawal.", comment: "a11y"), 498 badge: CONFIRM_BANK) 499 } } } } 500 @unknown default: 501 ErrorView(stack.push(), 502 title: "Unknown withdrawal type", // should not happen, so no L10N 503 message: withdrawalDetails.toJSON(), 504 copyable: true) 505 } // switch 506 } 507 } 508 // MARK: - 509 struct QRCodeDetails: View { 510 var transaction : TalerTransaction 511 var body: some View { 512 let details = transaction.detailsToShow() 513 let keys = details.keys 514 if keys.contains(TALERURI) { 515 if let talerURI = details[TALERURI] { 516 if talerURI.count > 10 { 517 QRCodeDetailView(talerURI: talerURI, 518 incoming: transaction.isP2pIncoming, 519 amount: transaction.common.amountRaw, 520 scope: transaction.common.scopes.first) 521 // scopes shouldn't (- but might) be nil! 522 } 523 } 524 } else if keys.contains(EXCHANGEBASEURL) { 525 if let baseURL = details[EXCHANGEBASEURL] { 526 Text("from \(baseURL.trimURL)", comment: "baseURL") 527 .talerFont(.title2) 528 .padding(.bottom) 529 } 530 } 531 } 532 } 533 // MARK: - 534 #if DEBUG 535 //struct TransactionSummary_Previews: PreviewProvider { 536 // static func deleteTransactionDummy(transactionId: String) async throws {} 537 // static func doneActionDummy() {} 538 // static var withdrawal = TalerTransaction(incoming: true, 539 // pending: true, 540 // id: "some withdrawal ID", 541 // time: Timestamp(from: 1_666_000_000_000)) 542 // static var payment = TalerTransaction(incoming: false, 543 // pending: false, 544 // id: "some payment ID", 545 // time: Timestamp(from: 1_666_666_000_000)) 546 // static func reloadActionDummy(transactionId: String) async -> TalerTransaction { return withdrawal } 547 // static var previews: some View { 548 // Group { 549 // TransactionSummaryV(transaction: withdrawal, reloadAction: reloadActionDummy, doneAction: doneActionDummy) 550 // TransactionSummaryV(transaction: payment, reloadAction: reloadActionDummy) 551 // } 552 // } 553 //} 554 #endif