taler-ios

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

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