taler-ios

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

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