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