TransactionRowView.swift (19950B)
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 os.log 10 import taler_swift 11 import SymLog 12 13 struct TransactionTimeline: View { 14 let timestamp: Timestamp 15 let textColor: Color 16 let layout: Int 17 let maxLines: Int 18 19 @AppStorage("minimalistic") var minimalistic: Bool = false 20 21 var body: some View { 22 TimelineView(.everyMinute) { context in 23 let (dateString, date) = TalerDater.dateString(timestamp, minimalistic, relative: true) 24 TruncationDetectingText(dateString, maxLines: maxLines, layout: layout, index: 1) 25 .foregroundColor(textColor) 26 .talerFont(.callout) 27 } 28 } 29 } 30 31 @MainActor 32 struct TransactionRowView: View { 33 let stack: CallStack 34 private let symLog = SymLogV(0) 35 let logger: Logger? 36 let scope: ScopeInfo 37 let transaction : TalerTransaction 38 39 @Environment(\.sizeCategory) var sizeCategory 40 @Environment(\.colorScheme) private var colorScheme 41 @Environment(\.colorSchemeContrast) private var colorSchemeContrast 42 @EnvironmentObject private var model: WalletModel 43 @AppStorage("minimalistic") var minimalistic: Bool = false 44 #if DEBUG 45 @AppStorage("developerMode") var developerMode: Bool = true 46 #else 47 @AppStorage("developerMode") var developerMode: Bool = false 48 #endif 49 @AppStorage("debugViews") var debugViews: Bool = false 50 51 @State private var merchantLogo: String? = nil 52 @State private var layoutStati0: [Int: Bool] = [:] 53 @State private var layoutStati1: [Int: Bool] = [:] 54 55 /// The first layout mode that can display the content without truncation 56 private var optimalLayout: Int? { 57 let keys0 = layoutStati0.keys.sorted(by: { $0 < $1 }) 58 let keys1 = layoutStati1.keys.sorted(by: { $0 < $1 }) 59 60 for key in keys0 { 61 let isTruncated0 = layoutStati0[key] ?? true 62 let isTruncated1 = layoutStati1[key] ?? true 63 if !isTruncated0 && !isTruncated1 { 64 return key 65 } 66 } 67 return keys0.last 68 } 69 70 private func isLayoutSelected(_ mode: Int) -> Bool { 71 return optimalLayout == mode 72 } 73 74 func needVStack(available: CGFloat, contentWidth: CGFloat, valueWidth: CGFloat) -> Bool { 75 available < (contentWidth + valueWidth + 40) 76 } 77 78 func topString(forA11y: Bool = false) -> String? { 79 switch transaction { 80 case .payment(let paymentTransaction): 81 return paymentTransaction.details.info?.merchant.name ?? "..." 82 case .peer2peer(let p2pTransaction): 83 return p2pTransaction.details.info.summary 84 default: 85 let result = transaction.isDone ? transaction.localizedTypePast 86 : transaction.localizedType 87 return forA11y ? result 88 : minimalistic ? nil 89 : result 90 } 91 } 92 #if TALER_NIGHTLY 93 var red: Color { developerMode && debugViews ? Color.red : Color.clear } 94 var green: Color { developerMode && debugViews ? Color.green : Color.clear } 95 var blue: Color { developerMode && debugViews ? Color.blue : Color.clear } 96 var orange: Color { developerMode && debugViews ? Color.orange : Color.clear } 97 var purple: Color { developerMode && debugViews ? Color.purple : Color.clear } 98 #endif 99 100 var common: TransactionCommon { transaction.common } 101 var done: Bool { transaction.isDone } 102 var isWorking: Bool { transaction.isWorking } 103 var pending: Bool { transaction.isPending || common.isFinalizing } 104 var needsKYC: Bool { transaction.isPendingKYC || transaction.isPendingKYCauth } 105 var doneOrPending: Bool { done || pending } 106 var donePendingDialog: Bool { doneOrPending || transaction.isDialog } 107 var shouldConfirm: Bool { transaction.shouldConfirm } 108 var isZero: Bool { common.amountEffective.isZero } 109 var incoming: Bool { common.isIncoming } 110 var refreshZero: Bool { common.type.isRefresh && isZero } 111 112 func textColor() -> Color { 113 let isDark = colorScheme == .dark 114 let increasedContrast = colorSchemeContrast == .increased 115 return doneOrPending ? .primary 116 : isDark ? .secondary 117 : increasedContrast ? Color(.darkGray) 118 : .secondary // Color(.tertiaryLabel) 119 } 120 var strikeColor: Color? { donePendingDialog ? nil : WalletColors().negative } 121 func foreColor(_ textColor: Color) -> Color { 122 refreshZero ? textColor 123 : pending ? WalletColors().pendingColor(incoming) 124 : done ? WalletColors().transactionColor(incoming) 125 : WalletColors().uncompletedColor 126 } 127 func for2Color(_ textColor: Color) -> Color { 128 let primaryAccent = WalletColors().primaryAccent 129 return refreshZero ? textColor 130 : doneOrPending ? (incoming ? primaryAccent : textColor) 131 : WalletColors().uncompletedColor 132 } 133 func iconBadge(_ foreColor: Color) -> TransactionIconBadge { 134 TransactionIconBadge(type: common.type, 135 foreColor: foreColor, 136 done: done, 137 incoming: incoming, 138 shouldConfirm: shouldConfirm && pending, 139 needsKYC: needsKYC && pending, 140 imageBase64: merchantLogo) 141 } 142 var topA11y: String { topString(forA11y: true)! } 143 var a11yLabel: String { donePendingDialog ? topA11y 144 : topA11y + String(localized: ", canceled", comment: "a11y") 145 } 146 var amountV: AmountV { AmountV(scope, transaction.amount(), 147 isNegative: isZero ? nil : !incoming, 148 strikethrough: !donePendingDialog) } 149 150 #if TALER_NIGHTLY 151 @ViewBuilder func layout0(_ topString: String?, _ textColor: Color, _ for2Color: Color) -> some View { 152 // orange amount right centered, top & bottom left 153 HStack { 154 VStack(alignment: .leading, spacing: 2) { 155 if let topString { 156 TruncationDetectingText(topString, 157 maxLines: 1, 158 layout: 0, 159 strikeColor: strikeColor) 160 .accessibilityLabel(a11yLabel) 161 .foregroundColor(textColor) 162 .talerFont(.headline) 163 .padding(.bottom, -2.0) 164 .overlay { Color.clear.border(red) } 165 } 166 TransactionTimeline(timestamp: common.timestamp, textColor: textColor, layout: 0, maxLines: 1) 167 .overlay { Color.clear.border(green) } 168 } 169 // .border(orange) 170 Spacer(minLength: 4) 171 amountV 172 .foregroundColor(for2Color) 173 .background(orange.opacity(0.2)) 174 } 175 } // layout0 176 #endif 177 178 #if TALER_NIGHTLY 179 @ViewBuilder func layout1(_ topString: String?, _ textColor: Color, _ for2Color: Color) -> some View { 180 // top full-width, bottom & green amount below 181 VStack(alignment: .leading, spacing: 2) { 182 if let topString { 183 TruncationDetectingText(topString, 184 maxLines: 10, 185 layout: 1, 186 strikeColor: strikeColor) 187 .accessibilityLabel(a11yLabel) 188 .foregroundColor(textColor) 189 .talerFont(.headline) 190 .padding(.bottom, -2.0) 191 .overlay { Color.clear.border(red) } 192 } 193 // spacing + Spacer will result in twice the spacing 194 // HStack(spacing: 6) { // will thrash if set to anything smaller than 5 195 HStack(spacing: 0) { // will thrash if set to anything smaller than 5 196 // onChange(of: CGSize) action tried to update multiple times per frame. 197 TransactionTimeline(timestamp: common.timestamp, textColor: textColor, layout: 1, maxLines: 1) 198 .overlay { Color.clear.border(green) } 199 Spacer(minLength: 4) // will thrash if set to 2 or more 200 amountV 201 .foregroundColor(for2Color) 202 .background(green.opacity(0.2)) 203 } 204 } 205 } // layout1 206 #endif 207 208 #if TALER_NIGHTLY 209 @ViewBuilder func layout2(_ topString: String?, _ textColor: Color, _ for2Color: Color) -> some View { 210 VStack(alignment: .leading, spacing: 0) { 211 let timeline = TransactionTimeline(timestamp: common.timestamp, textColor: textColor, layout: 2, maxLines: 10) 212 .overlay { Color.clear.border(green) } 213 if let topString { 214 // top & red amount, bottom below 215 HStack { 216 TruncationDetectingText(topString, 217 maxLines: 1, 218 layout: 2, 219 strikeColor: strikeColor) 220 .accessibilityLabel(a11yLabel) 221 .foregroundColor(textColor) 222 .talerFont(.headline) 223 .padding(.bottom, -2.0) 224 // .overlay { Color.clear.border(red) } 225 Spacer(minLength: 6) 226 amountV 227 .foregroundColor(for2Color) 228 .background(red.opacity(0.2)) 229 } 230 timeline 231 } else { // no top, bottom & purple amount 232 HStack { 233 timeline 234 Spacer(minLength: 6) 235 amountV 236 .foregroundColor(for2Color) 237 .background(purple.opacity(0.2)) 238 } 239 } 240 } 241 } // layout2 242 #endif 243 244 @ViewBuilder func layout3(_ topString: String?, _ textColor: Color, _ for2Color: Color) -> some View { 245 // top full-width, blue amount trailing, bottom full-width 246 let row = VStack(alignment: .leading, spacing: 2) { 247 if let topString { 248 TruncationDetectingText(topString, 249 maxLines: 10, 250 layout: 3, 251 strikeColor: strikeColor) 252 .accessibilityLabel(a11yLabel) 253 .foregroundColor(textColor) 254 // .strikethrough(!donePendingDialog, color: WalletColors().negative) 255 .talerFont(.headline) 256 // .fontWeight(.medium) iOS 16 only 257 .padding(.bottom, -2.0) 258 #if TALER_NIGHTLY 259 .overlay { Color.clear.border(red) } 260 #endif 261 } 262 // HStack(spacing: -4) { 263 HStack { 264 Spacer(minLength: 0) 265 amountV 266 .foregroundColor(for2Color) 267 #if TALER_NIGHTLY 268 .background(blue.opacity(0.2)) 269 #endif 270 } 271 TransactionTimeline(timestamp: common.timestamp, textColor: textColor, layout: 3, maxLines: 10) 272 #if TALER_NIGHTLY 273 .overlay { Color.clear.border(green) } 274 #endif 275 } 276 ZStack { 277 row 278 if isWorking { 279 RotatingTaler(size: 80, progress: true, once: false, 280 rotationEnabled: Binding.constant(true)) 281 } 282 } 283 } // layout3 284 285 #if TALER_NIGHTLY 286 @ViewBuilder func layout(_ topString: String?) -> some View { 287 let textColor = textColor() 288 let for2Color = for2Color(textColor) 289 ZStack { 290 layout0(topString, textColor, for2Color) 291 .layoutPriority(isLayoutSelected(0) ? 2 : 1) 292 .opacity(isLayoutSelected(0) ? 1 : 0) 293 layout1(topString, textColor, for2Color) 294 .layoutPriority(isLayoutSelected(1) ? 2 : 1) 295 .opacity(isLayoutSelected(1) ? 1 : 0) 296 layout2(topString, textColor, for2Color) 297 .layoutPriority(isLayoutSelected(2) ? 2 : 1) 298 .opacity(isLayoutSelected(2) ? 1 : 0) 299 layout3(topString, textColor, for2Color) 300 .layoutPriority(isLayoutSelected(3) ? 2 : 1) 301 .opacity(isLayoutSelected(3) ? 1 : 0) 302 } 303 .onPreferenceChange(LayoutTruncationStatus0.self) { stati in // top string 304 // logger?.log("LayoutTruncationStatus0") 305 DispatchQueue.main.async { 306 self.layoutStati0 = stati 307 } 308 } 309 .onPreferenceChange(LayoutTruncationStatus1.self) { stati in // Timeline 310 // logger?.log("LayoutTruncationStatus1") 311 DispatchQueue.main.async { 312 self.layoutStati1 = stati 313 } 314 } 315 } 316 #endif 317 318 func merchantLogo(talerTx: TalerTransaction) -> String? { 319 switch talerTx { 320 case .payment(let paymentTransaction): 321 if let terms = paymentTransaction.details.contractTerms { 322 return terms.merchant.logo 323 } 324 default: break 325 } 326 return nil 327 } 328 private func viewDidLoad() async { 329 if transaction.isPayment { 330 let txId = transaction.id 331 if merchantLogo == nil { 332 symLog.log("loading merchantLogo for \(txId)") 333 if let fullTx = try? await model.getTransactionById(txId, includeContractTerms: true) { 334 if let logo = merchantLogo(talerTx: fullTx) { 335 symLog("found terms.merchant.logo for \(txId)") 336 merchantLogo = logo 337 } else { 338 symLog("no logo") 339 } 340 } else { 341 symLog.log("loading \(txId) failed") 342 } 343 } else { 344 symLog.log("There is already a merchantLogo for \(txId)") 345 } 346 } 347 } 348 349 var body: some View { 350 #if DEBUG 351 // let _ = Self._printChanges() 352 // let _ = symLog.vlog() // just to get the # to compare it with .onAppear & onDisappear 353 #endif 354 // let details = transaction.detailsToShow() 355 // let keys = details.keys 356 let topString = topString() 357 let textColor = textColor() 358 let foreColor = foreColor(textColor) 359 let iconBadge = iconBadge(foreColor) 360 HStack { 361 iconBadge.talerFont(.title2) 362 #if TALER_NIGHTLY 363 if #available(iOS 18.0, *) { 364 layout(topString) 365 } else { 366 let for2Color = for2Color(textColor) 367 layout3(topString, textColor, for2Color) 368 } 369 #else // Stop using dynamic layouts for Taler Wallet and GNU Taler because of flickering 370 let for2Color = for2Color(textColor) 371 layout3(topString, textColor, for2Color) 372 #endif 373 } 374 .accessibilityElement(children: .combine) 375 .accessibilityValue(!donePendingDialog ? EMPTYSTRING 376 : needsKYC ? String(localized: ". Legitimization required", comment: "a11y") 377 : shouldConfirm ? String(localized: ". Needs bank authorization", comment: "a11y") 378 : EMPTYSTRING) 379 .accessibilityHint(String(localized: "Will go to detail view.", comment: "a11y")) 380 .task { 381 await viewDidLoad() 382 } 383 } 384 } 385 // MARK: - 386 #if DEBUG 387 struct TransactionRow_Previews: PreviewProvider { 388 static var withdrawal = TalerTransaction(incoming: true, 389 pending: false, 390 id: "some withdrawal ID", 391 time: Timestamp(from: 1_666_000_000_000)) 392 static var payment = TalerTransaction(incoming: false, 393 pending: false, 394 id: "some payment ID", 395 time: Timestamp(from: 1_666_666_000_000)) 396 @MainActor 397 struct StateContainer: View { 398 @State private var previewD = CurrencyInfo.zero(DEMOCURRENCY) 399 @State private var previewT = CurrencyInfo.zero(TESTCURRENCY) 400 401 var body: some View { 402 let scope = ScopeInfo.zero(DEMOCURRENCY) 403 List { 404 TransactionRowView(stack: CallStack(), logger: nil, scope: scope, transaction: withdrawal) 405 TransactionRowView(stack: CallStack(), logger: nil, scope: scope, transaction: payment) 406 } 407 } 408 } 409 410 static var previews: some View { 411 StateContainer() 412 // .environment(\.sizeCategory, .extraExtraLarge) Canvas Device Settings 413 } 414 } 415 // MARK: - 416 extension TalerTransaction { // for PreViews 417 init(incoming: Bool, pending: Bool, id: String, time: Timestamp) { 418 let txState = TransactionState(major: pending ? TransactionMajorState.pending 419 : TransactionMajorState.done) 420 let raw = Amount(currency: LONGCURRENCY, cent: 500) 421 let eff = Amount(currency: LONGCURRENCY, cent: incoming ? 480 : 520) 422 let common = TransactionCommon(type: incoming ? .withdrawal : .payment, 423 transactionId: id, 424 timestamp: time, 425 scopes: [], 426 txState: txState, 427 txActions: [.abort], 428 amountRaw: raw, 429 amountEffective: eff) 430 if incoming { 431 // if pending then manual else bank-integrated 432 let payto = "payto://iban/SANDBOXX/DE159593?receiver-name=Exchange+Company&amount=KUDOS%3A9.99&message=Taler+Withdrawal+J41FQPJGAP1BED1SFSXHC989EN8HRDYAHK688MQ228H6SKBMV0AG" 433 let withdrawalDetails = WithdrawalDetails(type: pending ? WithdrawalDetails.WithdrawalType.manual 434 : WithdrawalDetails.WithdrawalType.bankIntegrated, 435 reservePub: "PuBlIc_KeY_oF_tHe_ReSeRvE", 436 reserveIsReady: false, 437 confirmed: false) 438 let wDetails = WithdrawalTransactionDetails(exchangeBaseUrl: DEMOEXCHANGE, 439 withdrawalDetails: withdrawalDetails) 440 self = .withdrawal(WithdrawalTransaction(common: common, details: wDetails)) 441 } else { 442 let merchant = MerchantInfo(name: "some random shop") 443 let info = OrderShortInfo(orderId: "some order ID", 444 merchant: merchant, 445 summary: "some product summary", 446 products: []) 447 let pDetails = PaymentTransactionDetails(info: info, 448 totalRefundRaw: Amount(currency: LONGCURRENCY, cent: 300), 449 totalRefundEffective: Amount(currency: LONGCURRENCY, cent: 280), 450 refunds: [], 451 refundQueryActive: false, 452 contractTerms: nil, 453 choiceIndex: nil, 454 repurchaseTransactionId: nil, 455 abortReason: nil) 456 self = .payment(PaymentTransaction(common: common, details: pDetails)) 457 } 458 } 459 } 460 #endif