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