RefundView.swift (9721B)
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: - RefundFormView 20 21 struct RefundFormView: View { 22 @EnvironmentObject var refundManager: RefundManager 23 @EnvironmentObject var configManager: ConfigManager 24 @Environment(\.dismiss) private var dismiss 25 let item: OrderHistoryEntry 26 27 @State private var amount: Amount = .zero("") 28 @State private var reason: String = "" 29 @State private var errorText: String? = nil 30 @State private var showErrorAlert: (String, String)? = nil 31 @FocusState private var reasonFocused: Bool 32 33 private var numInputDigits: Int { 34 configManager.currencySpec?.numFractionalInputDigits ?? 2 35 } 36 37 var body: some View { 38 GeometryReader { geo in 39 ScrollView { 40 VStack(spacing: 12) { 41 Text(item.summary) 42 .font(.body) 43 .frame(maxWidth: .infinity, alignment: .leading) 44 .padding(.horizontal, 20) 45 .padding(.top, 8) 46 47 VStack(alignment: .leading, spacing: 4) { 48 Text(S.refundReason) 49 .font(.caption) 50 .foregroundColor(.posOnSurfaceVariant) 51 TextField(S.refundReason, text: $reason) 52 .focused($reasonFocused) 53 .submitLabel(.done) 54 .onSubmit { reasonFocused = false } 55 .padding(10) 56 .overlay( 57 RoundedRectangle(cornerRadius: 8) 58 .stroke(Color.posOutline, lineWidth: 1) 59 ) 60 } 61 .padding(.horizontal, 20) 62 63 HStack { 64 Spacer() 65 Text(amount.amountStr(numDigits: numInputDigits)) 66 .font(.system(size: 40, weight: .bold)) 67 .lineLimit(1) 68 .fixedSize(horizontal: true, vertical: false) 69 Text(item.amount.currency) 70 .font(.title3) 71 .foregroundColor(.posOnSurfaceVariant) 72 Spacer() 73 } 74 .padding(.vertical, 4) 75 .onTapGesture { reasonFocused = false } 76 77 if let errorText { 78 Text(errorText) 79 .font(.caption) 80 .foregroundColor(.posError) 81 } 82 83 AmountNumpad( 84 amount: $amount, 85 currency: item.amount.currency, 86 numInputDigits: numInputDigits, 87 isCompact: true, 88 onKeyPressed: { 89 reasonFocused = false 90 errorText = nil 91 } 92 ) 93 .frame(height: geo.size.height * 0.45) 94 .padding(.horizontal, 20) 95 96 Button(action: submitRefund) { 97 Text(S.refundConfirm) 98 .fontWeight(.semibold) 99 .frame(maxWidth: .infinity) 100 .frame(height: 50) 101 .background(!amount.isZero ? Color.posPrimary : Color.posSurfaceVariant) 102 .foregroundColor(!amount.isZero ? .posOnPrimary : .posOnSurfaceVariant) 103 .cornerRadius(12) 104 } 105 .disabled(amount.isZero) 106 .padding(.horizontal, 20) 107 108 Button(action: { 109 refundManager.abortRefund() 110 dismiss() 111 }) { 112 Text(S.paymentCancel) 113 .fontWeight(.semibold) 114 .frame(maxWidth: .infinity) 115 .frame(height: 50) 116 .background(Color.posErrorContainer) 117 .foregroundColor(.posOnErrorContainer) 118 .cornerRadius(12) 119 } 120 .padding(.horizontal, 20) 121 .padding(.bottom, 20) 122 } 123 } 124 .scrollDismissesKeyboard(.interactively) 125 } 126 .navigationTitle(S.historyRefund) 127 .navigationBarTitleDisplayMode(.inline) 128 .onAppear { 129 amount = item.amount 130 } 131 .onChange(of: refundManager.refundResult) { result in 132 guard let result = result else { return } 133 switch result { 134 case .error(let msg): 135 showErrorAlert = (S.historyRefund, msg) 136 case .pastDeadline: 137 showErrorAlert = (S.historyRefund, S.refundErrorDeadline) 138 case .alreadyRefunded: 139 showErrorAlert = (S.historyRefund, S.refundErrorAlreadyRefunded) 140 case .success: 141 break 142 } 143 } 144 .alert(S.commonError, isPresented: .init( 145 get: { showErrorAlert != nil }, 146 set: { if !$0 { showErrorAlert = nil } } 147 )) { 148 Button(S.commonOk) { showErrorAlert = nil } 149 } message: { 150 if let err = showErrorAlert { 151 Text("\(err.0): \(err.1)") 152 } 153 } 154 } 155 156 private func submitRefund() { 157 reasonFocused = false 158 let maxAmount = item.amount 159 160 if amount > maxAmount { 161 errorText = S.refundErrorMaxAmount(maxAmount.amountStr(numDigits: numInputDigits)) 162 return 163 } 164 if amount.isZero { 165 errorText = S.refundErrorZero 166 return 167 } 168 169 refundManager.refund(item: item, amount: amount, reason: reason) 170 } 171 } 172 173 // MARK: - RefundUriView 174 175 struct RefundUriView: View { 176 @EnvironmentObject var refundManager: RefundManager 177 @EnvironmentObject var configManager: ConfigManager 178 @Environment(\.dismiss) private var dismiss 179 180 private var isTablet: Bool { UIDevice.current.userInterfaceIdiom == .pad } 181 182 var body: some View { 183 Group { 184 if case .success(let refundUri, let item, let amount, let reason) = refundManager.refundResult { 185 GeometryReader { geo in 186 HStack(spacing: 0) { 187 VStack { 188 Spacer() 189 let qrSize = min(geo.size.width * (isTablet ? 0.45 : 0.4), geo.size.height * 0.7) 190 PosQRView(text: refundUri, size: qrSize) 191 Spacer() 192 } 193 .frame(width: geo.size.width * (isTablet ? 0.54 : 0.5)) 194 195 Divider() 196 197 VStack(spacing: 16) { 198 Spacer() 199 200 Text(S.refundIntro) 201 .font(.title3) 202 .multilineTextAlignment(.center) 203 204 Text(amount.readableAmount(numDigits: configManager.currencySpec?.displayDigits ?? 2)) 205 .font(.title) 206 .fontWeight(.bold) 207 208 Text(S.paymentOrderId(item.orderId) + "\n" + S.refundReason + ": " + reason) 209 .font(.body) 210 .foregroundColor(.secondary) 211 .multilineTextAlignment(.center) 212 213 Spacer() 214 215 Button(action: { 216 refundManager.abortRefund() 217 dismiss() 218 }) { 219 Text(S.refundAbort) 220 .fontWeight(.semibold) 221 .frame(maxWidth: .infinity) 222 .padding(.vertical, 12) 223 } 224 .buttonStyle(.borderedProminent) 225 .tint(.posError) 226 .foregroundColor(.posOnError) 227 } 228 .padding() 229 .frame(width: geo.size.width * (isTablet ? 0.46 : 0.5)) 230 } 231 } 232 } else { 233 VStack { 234 Spacer() 235 Text(S.refundStateMissing) 236 .foregroundColor(.secondary) 237 Spacer() 238 } 239 .onAppear { dismiss() } 240 } 241 } 242 .navigationTitle(S.historyRefund) 243 .navigationBarTitleDisplayMode(.inline) 244 .onChange(of: refundManager.refundReceived) { received in 245 if received { 246 refundManager.completeRefund() 247 } 248 } 249 } 250 }