taler-ios

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

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 }