taler-ios

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

HistoryView.swift (12426B)


      1 /*
      2  * This file is part of GNU Taler
      3  * (C) 2026 Bohdan, Volodymyr Potuzhnyi
      4  *
      5  * GNU Taler is free software; you can redistribute it and/or modify it under the
      6  * terms of the GNU General Public License as published by the Free Software
      7  * Foundation; either version 3, or (at your option) any later version.
      8  *
      9  * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
     10  * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
     11  * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
     12  *
     13  * You should have received a copy of the GNU General Public License along with
     14  * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
     15  */
     16 
     17 import SwiftUI
     18 
     19 
     20 enum HistoryStatus {
     21     case unpaid
     22     case paid
     23     case paymentPending
     24     case paymentClaimed
     25     case refundPending
     26     case refunded
     27 
     28     var label: String {
     29         switch self {
     30         case .unpaid: return S.historyUnpaid
     31         case .paid: return S.historyPaid
     32         case .paymentPending: return S.historyPaymentPending
     33         case .paymentClaimed: return S.historyPaymentClaimed
     34         case .refundPending: return S.historyRefundPending
     35         case .refunded: return S.historyRefunded
     36         }
     37     }
     38 
     39     var backgroundColor: Color {
     40         switch self {
     41         case .unpaid: return .posErrorContainer
     42         case .paid: return .posPrimaryContainer
     43         case .paymentPending: return .posSecondaryContainer
     44         case .paymentClaimed: return .posTertiaryContainer
     45         case .refundPending: return .posSecondaryContainer
     46         case .refunded: return .posTertiaryContainer
     47         }
     48     }
     49 
     50     var foregroundColor: Color {
     51         switch self {
     52         case .unpaid: return .posOnErrorContainer
     53         case .paid: return .posOnPrimaryContainer
     54         case .paymentPending: return .posOnSecondaryContainer
     55         case .paymentClaimed: return .posOnTertiaryContainer
     56         case .refundPending: return .posOnSecondaryContainer
     57         case .refunded: return .posOnTertiaryContainer
     58         }
     59     }
     60 }
     61 
     62 struct HistoryView: View {
     63     @EnvironmentObject var historyManager: HistoryManager
     64     @EnvironmentObject var refundManager: RefundManager
     65     @EnvironmentObject var paymentManager: PaymentManager
     66     @EnvironmentObject var configManager: ConfigManager
     67     let onNavigate: (PosDestination) -> Void
     68     var openDrawer: (() -> Void)?
     69     @State private var showRefundStateMissing = false
     70     @State private var toastMessage: String?
     71     @State private var manualRefreshTriggered = false
     72 
     73     var body: some View {
     74         VStack(spacing: 0) {
     75             PosTopBar(title: S.menuHistory, onMenuTap: { openDrawer?() }) {
     76                 Button(action: {
     77                     manualRefreshTriggered = true
     78                     toastMessage = S.toastHistoryRefreshing
     79                     historyManager.fetchHistory()
     80                 }) {
     81                     Text(S.historyRefresh)
     82                         .font(.subheadline)
     83                         .fontWeight(.medium)
     84                         .padding(.vertical, 8)
     85                         .padding(.horizontal, 14)
     86                         .frame(minHeight: 40)
     87                         .background(Color.posSecondaryContainer)
     88                         .foregroundColor(.posOnSecondaryContainer)
     89                         .clipShape(RoundedRectangle(cornerRadius: 20))
     90                 }
     91                 .buttonStyle(.plain)
     92             }
     93 
     94             if historyManager.isLoading && historyManager.items.isEmpty {
     95                 Spacer()
     96                 ProgressView(S.configFetching)
     97                 Spacer()
     98             } else if historyManager.items.isEmpty {
     99                 Spacer()
    100                 VStack(spacing: 16) {
    101                     Image(systemName: "clock")
    102                         .font(.system(size: 48))
    103                         .foregroundColor(.secondary)
    104                     Text(S.historyLabel)
    105                         .font(.title3)
    106                         .foregroundColor(.secondary)
    107                 }
    108                 Spacer()
    109             } else {
    110                 ScrollView {
    111                     LazyVStack(spacing: 12) {
    112                         ForEach(Array(historyManager.items.enumerated()), id: \.element.orderId) { index, item in
    113                             HistoryItemCard(
    114                                 item: item,
    115                                 hasPendingRefund: refundManager.pendingRefundOrderId == item.orderId,
    116                                 activePayment: paymentManager.payment?.orderId == item.orderId ? paymentManager.payment : nil,
    117                                 onRefundClicked: {
    118                                     refundManager.startRefund(item: item)
    119                                     onNavigate(.refund(item))
    120                                 },
    121                                 onDeleteClicked: {
    122                                     historyManager.deleteOrder(orderId: item.orderId)
    123                                 },
    124                                 onShowPaymentClicked: {
    125                                     paymentManager.resumePayment(item: item)
    126                                     onNavigate(.processPayment)
    127                                 },
    128                                 onShowRefundClicked: {
    129                                     if refundManager.resumeRefund(item: item) {
    130                                         onNavigate(.refundUri)
    131                                     } else {
    132                                         showRefundStateMissing = true
    133                                         historyManager.fetchHistory()
    134                                     }
    135                                 }
    136                             )
    137                             .onAppear {
    138                                 historyManager.loadMoreHistoryIfNeeded(lastVisibleIndex: index)
    139                             }
    140                         }
    141 
    142                         if historyManager.isLoadingMore {
    143                             HStack {
    144                                 Spacer()
    145                                 ProgressView()
    146                                 Spacer()
    147                             }
    148                             .padding(.vertical, 8)
    149                         }
    150                     }
    151                     .padding(16)
    152                 }
    153             }
    154         }
    155         .navigationBarHidden(true)
    156         .onAppear {
    157             print("[HistoryView] onAppear items=\(historyManager.items.count) isLoading=\(historyManager.isLoading)")
    158             if configManager.merchantConfig?.baseUrl == nil {
    159                 onNavigate(.config)
    160             } else {
    161                 historyManager.fetchHistory()
    162             }
    163         }
    164         .alert(S.forceDeleteTitle, isPresented: .init(
    165             get: { historyManager.forceDeleteOrderId != nil },
    166             set: { if !$0 { historyManager.clearForceDeletePrompt() } }
    167         )) {
    168             Button(S.forceDeleteConfirm, role: .destructive) {
    169                 if let orderId = historyManager.forceDeleteOrderId {
    170                     historyManager.forceDeleteOrder(orderId)
    171                 }
    172             }
    173             Button(S.paymentCancel, role: .cancel) {
    174                 historyManager.clearForceDeletePrompt()
    175             }
    176         } message: {
    177             Text(S.forceDeleteMessage)
    178         }
    179         .alert(S.errorHistory, isPresented: .init(
    180             get: { historyManager.errorMessage != nil },
    181             set: { if !$0 { historyManager.clearError() } }
    182         )) {
    183             Button(S.commonOk) { historyManager.clearError() }
    184         } message: {
    185             Text(historyManager.errorMessage ?? "")
    186         }
    187         .alert(S.commonError, isPresented: $showRefundStateMissing) {
    188             Button(S.commonOk) {}
    189         } message: {
    190             Text(S.refundStateMissing)
    191         }
    192         .toast(message: $toastMessage)
    193         .onChange(of: historyManager.isLoading) { loading in
    194             if !loading && manualRefreshTriggered {
    195                 manualRefreshTriggered = false
    196                 toastMessage = S.toastHistoryRefreshed
    197             }
    198         }
    199     }
    200 }
    201 
    202 // MARK: - HistoryItemCard
    203 
    204 struct HistoryItemCard: View {
    205     let item: OrderHistoryEntry
    206     let hasPendingRefund: Bool
    207     let activePayment: Payment?
    208     let onRefundClicked: () -> Void
    209     let onDeleteClicked: () -> Void
    210     let onShowPaymentClicked: () -> Void
    211     let onShowRefundClicked: () -> Void
    212 
    213     private var status: HistoryStatus {
    214         if item.hasPendingRefund { return .refundPending }
    215         if item.hasRefund { return .refunded }
    216         if hasPendingRefund { return .refundPending }
    217         if activePayment?.paid == true { return .paid }
    218         if activePayment?.claimed == true { return .paymentClaimed }
    219         if activePayment?.orderId == item.orderId && activePayment?.error == nil { return .paymentPending }
    220         if item.paid { return .paid }
    221         return .unpaid
    222     }
    223 
    224     var body: some View {
    225         VStack(alignment: .leading, spacing: 8) {
    226             HStack(alignment: .top, spacing: 12) {
    227                 VStack(alignment: .leading, spacing: 2) {
    228                     Text(item.summary)
    229                         .font(.headline)
    230                     Text(item.amount.readableAmount)
    231                         .font(.subheadline)
    232                 }
    233 
    234                 Spacer()
    235 
    236                 VStack(alignment: .trailing, spacing: 6) {
    237                     HistoryStatusBadge(status: status)
    238 
    239                     if item.hasPendingRefund, let pendingAmount = item.pendingRefundAmount, pendingAmount.isZero == false {
    240                         HistoryAmountBadge(amount: pendingAmount.readableAmount, status: status)
    241                     } else if item.hasRefund, let refundAmount = item.refundAmount {
    242                         HistoryAmountBadge(amount: refundAmount.readableAmount, status: status)
    243                     }
    244                 }
    245             }
    246 
    247             Text(item.timestamp.date, style: .relative)
    248                 .font(.subheadline)
    249                 .foregroundColor(.secondary)
    250 
    251             Text(S.historyRefNo(item.orderId))
    252                 .font(.caption)
    253                 .foregroundColor(.secondary)
    254 
    255             HStack(spacing: 8) {
    256                 if item.hasPendingRefund || hasPendingRefund {
    257                     Button(S.historyShowRefund, action: onShowRefundClicked)
    258                         .buttonStyle(.borderedProminent)
    259                         .tint(.posPrimary)
    260                         .foregroundColor(.posOnPrimary)
    261                         .controlSize(.small)
    262                 } else if item.refundable {
    263                     Button(S.historyRefund, action: onRefundClicked)
    264                         .buttonStyle(.borderedProminent)
    265                         .tint(.posPrimary)
    266                         .foregroundColor(.posOnPrimary)
    267                         .controlSize(.small)
    268                 } else if !item.paid {
    269                     Button(S.historyShowPayment, action: onShowPaymentClicked)
    270                         .buttonStyle(.borderedProminent)
    271                         .tint(.posPrimary)
    272                         .foregroundColor(.posOnPrimary)
    273                         .controlSize(.small)
    274                     Button(S.orderDelete, action: onDeleteClicked)
    275                         .buttonStyle(.bordered)
    276                         .tint(.posPrimary)
    277                         .controlSize(.small)
    278                 }
    279             }
    280         }
    281         .padding(16)
    282         .background(Color.posSurface)
    283         .cornerRadius(12)
    284         .overlay(
    285             RoundedRectangle(cornerRadius: 12)
    286                 .stroke(Color.posOutlineVariant, lineWidth: 1)
    287         )
    288     }
    289 }
    290 
    291 struct HistoryStatusBadge: View {
    292     let status: HistoryStatus
    293 
    294     var body: some View {
    295         Text(status.label)
    296             .font(.caption)
    297             .fontWeight(.medium)
    298             .padding(.horizontal, 10)
    299             .padding(.vertical, 4)
    300             .background(status.backgroundColor)
    301             .foregroundColor(status.foregroundColor)
    302             .cornerRadius(8)
    303     }
    304 }
    305 
    306 struct HistoryAmountBadge: View {
    307     let amount: String
    308     let status: HistoryStatus
    309 
    310     var body: some View {
    311         Text(amount)
    312             .font(.caption)
    313             .fontWeight(.medium)
    314             .padding(.horizontal, 10)
    315             .padding(.vertical, 4)
    316             .background(status.backgroundColor)
    317             .foregroundColor(status.foregroundColor)
    318             .cornerRadius(8)
    319     }
    320 }