taler-ios

iOS apps for GNU Taler (wallet)
Log | Files | Refs | README | LICENSE

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