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