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