PaymentView.swift (9930B)
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 // MARK: - ProcessPaymentView 20 21 struct ProcessPaymentView: View { 22 @EnvironmentObject var paymentManager: PaymentManager 23 @EnvironmentObject var orderManager: OrderManager 24 @EnvironmentObject var configManager: ConfigManager 25 let onNavigate: (PosDestination) -> Void 26 27 @State private var showForceDeleteDialog = false 28 @State private var errorTitle: String = "" 29 @State private var errorDetail: String = "" 30 @State private var showErrorAlert = false 31 @Environment(\.dismiss) private var dismiss 32 33 private var isTablet: Bool { UIDevice.current.userInterfaceIdiom == .pad } 34 35 var body: some View { 36 paymentBody 37 .navigationTitle(S.paymentProcessLabel) 38 .navigationBarTitleDisplayMode(.inline) 39 .onReceive(paymentManager.$payment) { payment in 40 guard let payment = payment else { return } 41 handlePaymentChange(payment) 42 } 43 .onReceive(paymentManager.$deleteNeedsForce) { needsForce in 44 handleDeleteNeedsForce(needsForce) 45 } 46 .alert(S.commonError, isPresented: $showErrorAlert) { 47 Button(S.commonOk) { dismiss() } 48 } message: { 49 Text("\(errorTitle)\n\(errorDetail)") 50 } 51 .alert(S.forceDeleteTitle, isPresented: $showForceDeleteDialog) { 52 Button(S.forceDeleteConfirm, role: .destructive) { 53 paymentManager.forceDeleteOrder() 54 dismiss() 55 } 56 Button(S.paymentCancel, role: .cancel) { 57 paymentManager.clearDeleteNeedsForce() 58 } 59 } message: { 60 Text(S.forceDeleteMessage) 61 } 62 } 63 64 @ViewBuilder 65 private var paymentBody: some View { 66 if let payment = paymentManager.payment { 67 GeometryReader { geo in 68 let leftWidth = geo.size.width * (isTablet ? 0.54 : 0.5) 69 let rightWidth = geo.size.width * (isTablet ? 0.46 : 0.5) 70 let qrSize = min(geo.size.width * (isTablet ? 0.45 : 0.4), geo.size.height * 0.7) 71 HStack(spacing: 0) { 72 PaymentQRColumn(payment: payment, width: leftWidth, qrSize: qrSize, onShare: shareText) 73 Divider() 74 PaymentInfoColumn(payment: payment, displayDigits: configManager.currencySpec?.displayDigits ?? 2, width: rightWidth, onCancel: onCancelTapped) 75 } 76 } 77 } else { 78 ProgressView(S.configFetching) 79 } 80 } 81 82 private func handlePaymentChange(_ payment: Payment) { 83 if payment.paid { return } 84 if let error = payment.error { 85 let display = getPaymentErrorDisplay(payment: payment, error: error) 86 errorTitle = display.0 87 errorDetail = display.1 88 showErrorAlert = true 89 } 90 } 91 92 private func handleDeleteNeedsForce(_ needsForce: Bool?) { 93 if needsForce == true { 94 showForceDeleteDialog = true 95 } else if needsForce == false { 96 paymentManager.clearDeleteNeedsForce() 97 dismiss() 98 } 99 } 100 101 private func onCancelTapped() { 102 if let payment = paymentManager.payment, payment.claimed { 103 paymentManager.tryDeleteOrder() 104 } else { 105 paymentManager.cancelPayment() 106 dismiss() 107 } 108 } 109 110 private func getPaymentErrorDisplay(payment: Payment, error: String) -> (String, String) { 111 if payment.orderId != nil { 112 return (S.errorPayment, error) 113 } 114 let normalized = error.lowercased() 115 if normalized.contains("inventory") || normalized.contains("stock") || 116 normalized.contains("insufficient") || normalized.contains("sold out") || 117 normalized.contains("out of stock") { 118 return (S.errorInventoryUnavailable, error) 119 } 120 return (S.errorOrderCreation, error) 121 } 122 123 private func shareText(_ text: String) { 124 guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, 125 let root = windowScene.windows.first?.rootViewController else { return } 126 let activityVC = UIActivityViewController(activityItems: [text], applicationActivities: nil) 127 if let popover = activityVC.popoverPresentationController { 128 popover.sourceView = root.view 129 popover.sourceRect = CGRect(x: root.view.bounds.midX, y: root.view.bounds.midY, width: 0, height: 0) 130 } 131 root.present(activityVC, animated: true) 132 } 133 } 134 135 // MARK: - Payment Sub-views 136 137 private struct PaymentQRColumn: View { 138 let payment: Payment 139 let width: CGFloat 140 let qrSize: CGFloat 141 let onShare: (String) -> Void 142 143 var body: some View { 144 VStack { 145 Spacer() 146 qrContent 147 Spacer() 148 shareButtons 149 } 150 .frame(width: width) 151 } 152 153 @ViewBuilder 154 private var qrContent: some View { 155 if !payment.claimed { 156 PosQRView(text: payment.talerPayUri, size: qrSize) 157 } 158 } 159 160 @ViewBuilder 161 private var shareButtons: some View { 162 if !payment.claimed, let payUri = payment.talerPayUri { 163 HStack(spacing: 12) { 164 Button(S.paymentShareUri) { onShare(payUri) } 165 .buttonStyle(.bordered) 166 Button(S.paymentCopyUri) { UIPasteboard.general.string = payUri } 167 .buttonStyle(.bordered) 168 } 169 .padding(.bottom, 12) 170 } 171 } 172 } 173 174 private struct PaymentInfoColumn: View { 175 let payment: Payment 176 var displayDigits: Int = 2 177 let width: CGFloat 178 let onCancel: () -> Void 179 180 private var introText: String { 181 if payment.claimed { 182 return S.paymentClaimed 183 } else if payment.talerPayUri != nil { 184 return S.paymentIntro 185 } else { 186 return S.paymentFetchingLink 187 } 188 } 189 190 var body: some View { 191 VStack(spacing: 16) { 192 Spacer() 193 Text(introText) 194 .font(.title3) 195 .multilineTextAlignment(.center) 196 Text(payment.order.total.readableAmount(numDigits: displayDigits)) 197 .font(.title) 198 .fontWeight(.bold) 199 orderIdView 200 Spacer() 201 cancelButton 202 } 203 .padding() 204 .frame(width: width) 205 } 206 207 @ViewBuilder 208 private var orderIdView: some View { 209 if let orderId = payment.orderId { 210 Text(S.paymentOrderId(orderId)) 211 .font(.body) 212 .foregroundColor(.secondary) 213 } 214 } 215 216 private var cancelButton: some View { 217 Button(action: onCancel) { 218 Text(S.paymentCancel) 219 .fontWeight(.semibold) 220 .frame(maxWidth: .infinity) 221 .padding(.vertical, 12) 222 } 223 .buttonStyle(.borderedProminent) 224 .tint(.posError) 225 .foregroundColor(.posOnError) 226 } 227 } 228 229 // MARK: - PaymentSuccessView 230 231 struct PaymentSuccessView: View { 232 let onNavigate: (PosDestination) -> Void 233 @EnvironmentObject var paymentManager: PaymentManager 234 @Environment(\.dismiss) private var dismiss 235 236 var body: some View { 237 GeometryReader { geo in 238 let iconSize: CGFloat = min(geo.size.height * 0.22, 164.0) 239 let buttonWidth: CGFloat = geo.size.width < 600 ? geo.size.width * 0.78 : geo.size.width * 0.6 240 241 VStack(spacing: 0) { 242 Spacer() 243 successHero(iconSize: iconSize, height: geo.size.height) 244 Spacer() 245 continueButton(width: buttonWidth, height: geo.size.height) 246 } 247 .frame(maxWidth: .infinity) 248 .padding(.horizontal, 24) 249 } 250 .navigationTitle(S.paymentReceived) 251 .navigationBarTitleDisplayMode(.inline) 252 .navigationBarBackButtonHidden(true) 253 } 254 255 private func successHero(iconSize: CGFloat, height: CGFloat) -> some View { 256 VStack(spacing: max(height * 0.035, 12)) { 257 Image(systemName: "checkmark.circle.fill") 258 .resizable() 259 .scaledToFit() 260 .frame(width: iconSize, height: iconSize) 261 .foregroundColor(.posPrimary) 262 263 Text(S.paymentReceived) 264 .font(height < 560 ? .title2 : height < 720 ? .title : .largeTitle) 265 .fontWeight(.semibold) 266 .foregroundColor(.posPrimary) 267 .multilineTextAlignment(.center) 268 } 269 } 270 271 private func continueButton(width: CGFloat, height: CGFloat) -> some View { 272 Button { 273 let destination: PosDestination = paymentManager.payment?.origin == .amountEntry ? .amountEntry : .order 274 paymentManager.reset() 275 onNavigate(destination) 276 } label: { 277 Text(S.paymentBackButton) 278 .fontWeight(.semibold) 279 .frame(width: width) 280 .padding(.vertical, max(height * 0.02, 14)) 281 } 282 .buttonStyle(.borderedProminent) 283 .padding(.bottom, max(height * 0.045, 16)) 284 } 285 } 286