TransactionRowView.swift (20175B)
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 donePendingDialog ? .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 #if DEBUG 280 let background = developerMode ? Color.green : nil 281 #else 282 let background: Color? = nil 283 #endif 284 RotatingTaler(size: 80, progress: true, once: false, 285 background: background, 286 rotationEnabled: Binding.constant(true)) 287 } 288 } 289 } // layout3 290 291 #if TALER_NIGHTLY 292 @ViewBuilder func layout(_ topString: String?) -> some View { 293 let textColor = textColor() 294 let for2Color = for2Color(textColor) 295 ZStack { 296 layout0(topString, textColor, for2Color) 297 .layoutPriority(isLayoutSelected(0) ? 2 : 1) 298 .opacity(isLayoutSelected(0) ? 1 : 0) 299 layout1(topString, textColor, for2Color) 300 .layoutPriority(isLayoutSelected(1) ? 2 : 1) 301 .opacity(isLayoutSelected(1) ? 1 : 0) 302 layout2(topString, textColor, for2Color) 303 .layoutPriority(isLayoutSelected(2) ? 2 : 1) 304 .opacity(isLayoutSelected(2) ? 1 : 0) 305 layout3(topString, textColor, for2Color) 306 .layoutPriority(isLayoutSelected(3) ? 2 : 1) 307 .opacity(isLayoutSelected(3) ? 1 : 0) 308 } 309 .onPreferenceChange(LayoutTruncationStatus0.self) { stati in // top string 310 // logger?.log("LayoutTruncationStatus0") 311 DispatchQueue.main.async { 312 self.layoutStati0 = stati 313 } 314 } 315 .onPreferenceChange(LayoutTruncationStatus1.self) { stati in // Timeline 316 // logger?.log("LayoutTruncationStatus1") 317 DispatchQueue.main.async { 318 self.layoutStati1 = stati 319 } 320 } 321 } 322 #endif 323 324 func merchantLogo(talerTx: TalerTransaction) -> String? { 325 switch talerTx { 326 case .payment(let paymentTransaction): 327 if let terms = paymentTransaction.details.contractTerms { 328 return terms.merchant.logo 329 } 330 default: break 331 } 332 return nil 333 } 334 private func viewDidLoad() async { 335 if transaction.isPayment { 336 let txId = transaction.id 337 if merchantLogo == nil { 338 symLog.log("loading merchantLogo for \(txId)") 339 if let fullTx = try? await model.getTransactionById(txId, includeContractTerms: true) { 340 if let logo = merchantLogo(talerTx: fullTx) { 341 symLog("found terms.merchant.logo for \(txId)") 342 merchantLogo = logo 343 } else { 344 symLog("no logo") 345 } 346 } else { 347 symLog.log("loading \(txId) failed") 348 } 349 } else { 350 symLog.log("There is already a merchantLogo for \(txId)") 351 } 352 } 353 } 354 355 var body: some View { 356 #if DEBUG 357 // let _ = Self._printChanges() 358 // let _ = symLog.vlog() // just to get the # to compare it with .onAppear & onDisappear 359 #endif 360 // let details = transaction.detailsToShow() 361 // let keys = details.keys 362 let topString = topString() 363 let textColor = textColor() 364 let foreColor = foreColor(textColor) 365 let iconBadge = iconBadge(foreColor) 366 HStack { 367 iconBadge.talerFont(.title2) 368 #if TALER_NIGHTLY 369 if #available(iOS 18.0, *) { 370 layout(topString) 371 } else { 372 let for2Color = for2Color(textColor) 373 layout3(topString, textColor, for2Color) 374 } 375 #else // Stop using dynamic layouts for Taler Wallet and GNU Taler because of flickering 376 let for2Color = for2Color(textColor) 377 layout3(topString, textColor, for2Color) 378 #endif 379 } 380 .accessibilityElement(children: .combine) 381 .accessibilityValue(!donePendingDialog ? EMPTYSTRING 382 : needsKYC ? String(localized: ". Legitimization required", comment: "a11y") 383 : shouldConfirm ? String(localized: ". Needs bank authorization", comment: "a11y") 384 : EMPTYSTRING) 385 .accessibilityHint(String(localized: "Will go to detail view.", comment: "a11y")) 386 .task { 387 await viewDidLoad() 388 } 389 } 390 } 391 // MARK: - 392 #if DEBUG 393 struct TransactionRow_Previews: PreviewProvider { 394 static var withdrawal = TalerTransaction(incoming: true, 395 pending: false, 396 id: "some withdrawal ID", 397 time: Timestamp(from: 1_666_000_000_000)) 398 static var payment = TalerTransaction(incoming: false, 399 pending: false, 400 id: "some payment ID", 401 time: Timestamp(from: 1_666_666_000_000)) 402 @MainActor 403 struct StateContainer: View { 404 @State private var previewD = CurrencyInfo.zero(DEMOCURRENCY) 405 @State private var previewT = CurrencyInfo.zero(TESTCURRENCY) 406 407 var body: some View { 408 let scope = ScopeInfo.zero(DEMOCURRENCY) 409 List { 410 TransactionRowView(stack: CallStack(), logger: nil, scope: scope, transaction: withdrawal) 411 TransactionRowView(stack: CallStack(), logger: nil, scope: scope, transaction: payment) 412 } 413 } 414 } 415 416 static var previews: some View { 417 StateContainer() 418 // .environment(\.sizeCategory, .extraExtraLarge) Canvas Device Settings 419 } 420 } 421 // MARK: - 422 extension TalerTransaction { // for PreViews 423 init(incoming: Bool, pending: Bool, id: String, time: Timestamp) { 424 let txState = TransactionState(major: pending ? TransactionMajorState.pending 425 : TransactionMajorState.done) 426 let raw = Amount(currency: LONGCURRENCY, cent: 500) 427 let eff = Amount(currency: LONGCURRENCY, cent: incoming ? 480 : 520) 428 let common = TransactionCommon(type: incoming ? .withdrawal : .payment, 429 transactionId: id, 430 timestamp: time, 431 scopes: [], 432 txState: txState, 433 txActions: [.abort], 434 amountRaw: raw, 435 amountEffective: eff) 436 if incoming { 437 // if pending then manual else bank-integrated 438 let payto = "payto://iban/SANDBOXX/DE159593?receiver-name=Exchange+Company&amount=KUDOS%3A9.99&message=Taler+Withdrawal+J41FQPJGAP1BED1SFSXHC989EN8HRDYAHK688MQ228H6SKBMV0AG" 439 let withdrawalDetails = WithdrawalDetails(type: pending ? WithdrawalDetails.WithdrawalType.manual 440 : WithdrawalDetails.WithdrawalType.bankIntegrated, 441 reservePub: "PuBlIc_KeY_oF_tHe_ReSeRvE", 442 reserveIsReady: false, 443 confirmed: false) 444 let wDetails = WithdrawalTransactionDetails(exchangeBaseUrl: DEMOEXCHANGE, 445 withdrawalDetails: withdrawalDetails) 446 self = .withdrawal(WithdrawalTransaction(common: common, details: wDetails)) 447 } else { 448 let merchant = MerchantInfo(name: "some random shop") 449 let info = OrderShortInfo(orderId: "some order ID", 450 merchant: merchant, 451 summary: "some product summary", 452 // products: [] 453 ) 454 let pDetails = PaymentTransactionDetails(info: info, 455 totalRefundRaw: Amount(currency: LONGCURRENCY, cent: 300), 456 totalRefundEffective: Amount(currency: LONGCURRENCY, cent: 280), 457 refunds: [], 458 refundQueryActive: false, 459 contractTerms: nil, 460 choiceIndex: nil, 461 repurchaseTransactionId: nil, 462 abortReason: nil) 463 self = .payment(PaymentTransaction(common: common, details: pDetails)) 464 } 465 } 466 } 467 #endif