TransactionRowView.swift (16182B)
1 /* 2 * This file is part of GNU Taler, ©2022-25 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 private let symLog = SymLogV(0) 34 let logger: Logger? 35 let scope: ScopeInfo 36 let transaction : TalerTransaction 37 38 @Environment(\.sizeCategory) var sizeCategory 39 @Environment(\.colorScheme) private var colorScheme 40 @Environment(\.colorSchemeContrast) private var colorSchemeContrast 41 @AppStorage("minimalistic") var minimalistic: Bool = false 42 43 @State private var layoutStati0: [Int: Bool] = [:] 44 @State private var layoutStati1: [Int: Bool] = [:] 45 46 /// The first layout mode that can display the content without truncation 47 private var optimalLayout: Int? { 48 let keys0 = layoutStati0.keys.sorted(by: { $0 < $1 }) 49 let keys1 = layoutStati1.keys.sorted(by: { $0 < $1 }) 50 51 for key in keys0 { 52 let isTruncated0 = layoutStati0[key] ?? true 53 let isTruncated1 = layoutStati1[key] ?? true 54 if !isTruncated0 && !isTruncated1 { 55 return key 56 } 57 } 58 return keys0.last 59 } 60 61 private func isLayoutSelected(_ mode: Int) -> Bool { 62 return optimalLayout == mode 63 } 64 65 func needVStack(available: CGFloat, contentWidth: CGFloat, valueWidth: CGFloat) -> Bool { 66 available < (contentWidth + valueWidth + 40) 67 } 68 69 func topString(forA11y: Bool = false) -> String? { 70 switch transaction { 71 case .payment(let paymentTransaction): 72 return paymentTransaction.details.info.merchant.name 73 case .peer2peer(let p2pTransaction): 74 return p2pTransaction.details.info.summary 75 default: 76 let result = transaction.isDone ? transaction.localizedTypePast 77 : transaction.localizedType 78 return forA11y ? result 79 : minimalistic ? nil 80 : result 81 } 82 } 83 84 func amount() -> Amount { 85 switch transaction { 86 case .refresh(let refreshTransaction): 87 let details = refreshTransaction.details 88 return details.refreshInputAmount 89 default: 90 let common = transaction.common 91 let eff = common.amountEffective 92 if !eff.isZero { return eff } 93 return common.amountRaw 94 } 95 } 96 97 var body: some View { 98 #if DEBUG 99 // let _ = Self._printChanges() 100 let _ = symLog.vlog() // just to get the # to compare it with .onAppear & onDisappear 101 #endif 102 let pending = transaction.isPending || transaction.common.isFinalizing 103 let needsKYC = transaction.isPendingKYC || transaction.isPendingKYCauth 104 let shouldConfirm = transaction.shouldConfirm 105 let done = transaction.isDone 106 let isDialog = transaction.isDialog 107 let doneOrPending = done || pending 108 let donePendingDialog = doneOrPending || isDialog 109 let increasedContrast = colorSchemeContrast == .increased 110 let details = transaction.detailsToShow() 111 let keys = details.keys 112 let common = transaction.common 113 let isZero = common.amountEffective.isZero 114 let incoming = common.isIncoming 115 let textColor = doneOrPending ? .primary 116 : colorScheme == .dark ? .secondary 117 : increasedContrast ? Color(.darkGray) 118 : .secondary // Color(.tertiaryLabel) 119 let refreshZero = common.type.isRefresh && isZero 120 let foreColor = refreshZero ? textColor 121 : pending ? WalletColors().pendingColor(incoming) 122 : done ? WalletColors().transactionColor(incoming) 123 : WalletColors().uncompletedColor 124 let for2Color = refreshZero ? textColor 125 : (pending || done) ? (incoming ? .accentColor : textColor) 126 : WalletColors().uncompletedColor 127 128 let iconBadge = TransactionIconBadge(type: common.type, foreColor: foreColor, 129 done: done, incoming: incoming, 130 shouldConfirm: shouldConfirm && pending, 131 needsKYC: needsKYC && pending) 132 let amountV = AmountV(scope, amount(), isNegative: isZero ? nil : !incoming, 133 strikethrough: !donePendingDialog) 134 .foregroundColor(for2Color) 135 let topA11y = topString(forA11y: true)! 136 let topString = topString() 137 let strikeColor = donePendingDialog ? nil : WalletColors().negative 138 let a11yLabel = donePendingDialog ? topA11y 139 : topA11y + String(localized: ", canceled", comment: "a11y") 140 141 #if DEBUG 142 let debug = 1==0 143 let red = debug ? Color.red : Color.clear 144 let green = debug ? Color.green : Color.clear 145 let blue = debug ? Color.blue : Color.clear 146 let orange = debug ? Color.orange : Color.clear 147 let purple = debug ? Color.purple : Color.clear 148 #endif 149 150 let layout0 = HStack(spacing: 4) { // amount right centered, top & bottom left 151 VStack(alignment: .leading, spacing: 2) { 152 if let topString { 153 TruncationDetectingText(topString, 154 maxLines: 1, 155 layout: 0, 156 strikeColor: strikeColor) 157 .foregroundColor(textColor) 158 .talerFont(.headline) 159 .padding(.bottom, -2.0) 160 #if DEBUG 161 .border(red) 162 #endif 163 .accessibilityLabel(a11yLabel) 164 } 165 TransactionTimeline(timestamp: common.timestamp, textColor: textColor, layout: 0, maxLines: 1) 166 #if DEBUG 167 .border(green) 168 #endif 169 } 170 // .border(orange) 171 Spacer(minLength: 0) 172 amountV //.frame(maxWidth: .infinity, alignment: .trailing) 173 #if DEBUG 174 .background(orange.opacity(0.2)) 175 #endif 176 } 177 178 let layout1 = VStack(alignment: .leading, spacing: 2) { // top full-width, bottom & amount below 179 if let topString { 180 TruncationDetectingText(topString, 181 maxLines: 10, 182 layout: 1, 183 strikeColor: strikeColor) 184 .foregroundColor(textColor) 185 .talerFont(.headline) 186 .padding(.bottom, -2.0) 187 #if DEBUG 188 .border(red) 189 #endif 190 .accessibilityLabel(a11yLabel) 191 } 192 HStack(spacing: 6) { 193 TransactionTimeline(timestamp: common.timestamp, textColor: textColor, layout: 1, maxLines: 1) 194 #if DEBUG 195 .border(green) 196 #endif 197 Spacer(minLength: 0) 198 amountV 199 #if DEBUG 200 .background(green.opacity(0.2)) 201 #endif 202 } 203 } 204 205 let layout2 = VStack(alignment: .leading, spacing: 0) { 206 if let topString { // top & amount, bottom below 207 HStack(spacing: 6) { 208 TruncationDetectingText(topString, 209 maxLines: 1, 210 layout: 2, 211 strikeColor: strikeColor) 212 .foregroundColor(textColor) 213 .talerFont(.headline) 214 .padding(.bottom, -2.0) 215 .accessibilityLabel(a11yLabel) 216 #if DEBUG 217 // .border(red) 218 #endif 219 Spacer(minLength: 2) 220 amountV 221 #if DEBUG 222 .background(red.opacity(0.2)) 223 #endif 224 } 225 TransactionTimeline(timestamp: common.timestamp, textColor: textColor, layout: 2, maxLines: 10) 226 #if DEBUG 227 .border(green) 228 #endif 229 } else { // no top, bottom & amount 230 HStack(spacing: 6) { 231 TransactionTimeline(timestamp: common.timestamp, textColor: textColor, layout: 2, maxLines: 10) 232 #if DEBUG 233 .border(green) 234 #endif 235 Spacer(minLength: 2) 236 amountV 237 #if DEBUG 238 .background(purple.opacity(0.2)) 239 #endif 240 } 241 } 242 } 243 244 let layout3 = VStack(alignment: .leading, spacing: 2) { // top full-width, amount trailing, bottom full-width 245 if let topString { 246 TruncationDetectingText(topString, 247 maxLines: 10, 248 layout: 3, 249 strikeColor: strikeColor) 250 .foregroundColor(textColor) 251 // .strikethrough(!donePendingDialog, color: WalletColors().negative) 252 .talerFont(.headline) 253 // .fontWeight(.medium) iOS 16 only 254 .padding(.bottom, -2.0) 255 #if DEBUG 256 .border(red) 257 #endif 258 .accessibilityLabel(a11yLabel) 259 260 } 261 HStack(spacing: -4) { 262 Spacer(minLength: 0) 263 amountV 264 #if DEBUG 265 .background(blue.opacity(0.2)) 266 #endif 267 } 268 TransactionTimeline(timestamp: common.timestamp, textColor: textColor, layout: 3, maxLines: 10) 269 #if DEBUG 270 .border(green) 271 #endif 272 } 273 274 HStack { 275 iconBadge.talerFont(.title2) 276 #if DEBUG 277 .border(blue) 278 #endif 279 ZStack { 280 layout0 281 .layoutPriority(isLayoutSelected(0) ? 2 : 1) 282 .opacity(isLayoutSelected(0) ? 1 : 0) 283 layout1 284 .layoutPriority(isLayoutSelected(1) ? 2 : 1) 285 .opacity(isLayoutSelected(1) ? 1 : 0) 286 layout2 287 .layoutPriority(isLayoutSelected(2) ? 2 : 1) 288 .opacity(isLayoutSelected(2) ? 1 : 0) 289 layout3 290 .layoutPriority(isLayoutSelected(3) ? 2 : 1) 291 .opacity(isLayoutSelected(3) ? 1 : 0) 292 } 293 .onPreferenceChange(LayoutTruncationStatus0.self) { stati in // top string 294 // logger?.log("LayoutTruncationStatus0") 295 DispatchQueue.main.async { 296 self.layoutStati0 = stati 297 } 298 } 299 .onPreferenceChange(LayoutTruncationStatus1.self) { stati in // Timeline 300 // logger?.log("LayoutTruncationStatus1") 301 DispatchQueue.main.async { 302 self.layoutStati1 = stati 303 } 304 } 305 } 306 .accessibilityElement(children: .combine) 307 .accessibilityValue(!donePendingDialog ? EMPTYSTRING 308 : needsKYC ? String(localized: ". Legitimization required", comment: "a11y") 309 : shouldConfirm ? String(localized: ". Needs bank authorization", comment: "a11y") 310 : EMPTYSTRING) 311 .accessibilityHint(String(localized: "Will go to detail view.", comment: "a11y")) 312 } 313 } 314 315 316 317 318 // MARK: - 319 #if DEBUG 320 struct TransactionRow_Previews: PreviewProvider { 321 static var withdrawal = TalerTransaction(incoming: true, 322 pending: false, 323 id: "some withdrawal ID", 324 time: Timestamp(from: 1_666_000_000_000)) 325 static var payment = TalerTransaction(incoming: false, 326 pending: false, 327 id: "some payment ID", 328 time: Timestamp(from: 1_666_666_000_000)) 329 @MainActor 330 struct StateContainer: View { 331 @State private var previewD = CurrencyInfo.zero(DEMOCURRENCY) 332 @State private var previewT = CurrencyInfo.zero(TESTCURRENCY) 333 334 var body: some View { 335 let scope = ScopeInfo.zero(DEMOCURRENCY) 336 List { 337 TransactionRowView(logger: nil, scope: scope, transaction: withdrawal) 338 TransactionRowView(logger: nil, scope: scope, transaction: payment) 339 } 340 } 341 } 342 343 static var previews: some View { 344 StateContainer() 345 // .environment(\.sizeCategory, .extraExtraLarge) Canvas Device Settings 346 } 347 } 348 // MARK: - 349 extension TalerTransaction { // for PreViews 350 init(incoming: Bool, pending: Bool, id: String, time: Timestamp) { 351 let txState = TransactionState(major: pending ? TransactionMajorState.pending 352 : TransactionMajorState.done) 353 let raw = Amount(currency: LONGCURRENCY, cent: 500) 354 let eff = Amount(currency: LONGCURRENCY, cent: incoming ? 480 : 520) 355 let common = TransactionCommon(type: incoming ? .withdrawal : .payment, 356 transactionId: id, 357 timestamp: time, 358 scopes: [], 359 txState: txState, 360 txActions: [.abort], 361 amountRaw: raw, 362 amountEffective: eff) 363 if incoming { 364 // if pending then manual else bank-integrated 365 let payto = "payto://iban/SANDBOXX/DE159593?receiver-name=Exchange+Company&amount=KUDOS%3A9.99&message=Taler+Withdrawal+J41FQPJGAP1BED1SFSXHC989EN8HRDYAHK688MQ228H6SKBMV0AG" 366 let withdrawalDetails = WithdrawalDetails(type: pending ? WithdrawalDetails.WithdrawalType.manual 367 : WithdrawalDetails.WithdrawalType.bankIntegrated, 368 reservePub: "PuBlIc_KeY_oF_tHe_ReSeRvE", 369 reserveIsReady: false, 370 confirmed: false) 371 let wDetails = WithdrawalTransactionDetails(exchangeBaseUrl: DEMOEXCHANGE, 372 withdrawalDetails: withdrawalDetails) 373 self = .withdrawal(WithdrawalTransaction(common: common, details: wDetails)) 374 } else { 375 let merchant = Merchant(name: "some random shop") 376 let info = OrderShortInfo(orderId: "some order ID", 377 merchant: merchant, 378 summary: "some product summary", 379 products: []) 380 let pDetails = PaymentTransactionDetails(info: info, 381 totalRefundRaw: Amount(currency: LONGCURRENCY, cent: 300), 382 totalRefundEffective: Amount(currency: LONGCURRENCY, cent: 280), 383 refunds: [], 384 refundQueryActive: false) 385 self = .payment(PaymentTransaction(common: common, details: pDetails)) 386 } 387 } 388 } 389 #endif