taler-ios

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

TransactionRowView.swift (19950B)


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