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 }