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