taler-ios

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

AmountEntryView.swift (11586B)


      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 private let quickAmountProductId = "quick_amount"
     20 
     21 struct AmountEntryView: View {
     22     @EnvironmentObject var configManager: ConfigManager
     23     @EnvironmentObject var paymentManager: PaymentManager
     24     @EnvironmentObject var orderManager: OrderManager
     25     let onNavigate: (PosDestination) -> Void
     26     var openDrawer: (() -> Void)?
     27 
     28     @State private var amount: Amount = .zero("")
     29     @State private var selectedCurrency: String = ""
     30     @State private var errorMessage: String?
     31 
     32     private var numInputDigits: Int {
     33         configManager.currencySpec?.numFractionalInputDigits ?? 2
     34     }
     35 
     36     private var isTablet: Bool {
     37         UIDevice.current.userInterfaceIdiom == .pad
     38     }
     39 
     40     private var amountText: String {
     41         amount.amountStr(numDigits: numInputDigits)
     42     }
     43 
     44     var body: some View {
     45         VStack(spacing: 0) {
     46             PosTopBar(title: S.menuAmountEntry, onMenuTap: { openDrawer?() })
     47 
     48             if isTablet {
     49                 tabletLayout
     50             } else {
     51                 phoneLayout
     52             }
     53         }
     54         .navigationBarHidden(true)
     55         .onAppear {
     56             if let currency = configManager.currency {
     57                 selectedCurrency = currency
     58                 amount = .zero(currency)
     59             }
     60         }
     61     }
     62 
     63     // MARK: - Phone: horizontal Row (amount 32% | keypad 68%)
     64 
     65     private var phoneLayout: some View {
     66         GeometryReader { geo in
     67             HStack(spacing: 8) {
     68                 phoneAmountPane
     69                     .frame(width: geo.size.width * 0.32)
     70                     .padding(.horizontal, 8)
     71                     .padding(.vertical, 4)
     72 
     73                 keypadPane(isCompact: geo.size.height <= 540)
     74                     .frame(width: geo.size.width * 0.68 - 16)
     75                     .padding(4)
     76             }
     77             .padding(8)
     78         }
     79     }
     80 
     81     // MARK: - Tablet: vertical Column (amount 35% | keypad 65% at 60% width)
     82 
     83     private var tabletLayout: some View {
     84         GeometryReader { geo in
     85             VStack(spacing: 12) {
     86                 tabletAmountPane
     87                     .frame(maxHeight: geo.size.height * 0.35)
     88 
     89                 HStack {
     90                     Spacer()
     91                     keypadPane(isCompact: false)
     92                         .frame(width: geo.size.width * 0.6)
     93                     Spacer()
     94                 }
     95             }
     96             .padding(16)
     97             .padding(.bottom, geo.safeAreaInsets.bottom > 0 ? 0 : 16)
     98         }
     99     }
    100 
    101     // MARK: - Phone Amount Pane (vertical: amount text, currency dropdown)
    102 
    103     private var phoneAmountPane: some View {
    104         VStack(spacing: 12) {
    105             phoneAmountText
    106 
    107             currencyDropdown
    108 
    109             Spacer()
    110 
    111             if let errorMessage {
    112                 Text(errorMessage)
    113                     .font(.caption)
    114                     .foregroundColor(.posError)
    115             }
    116         }
    117     }
    118 
    119     private var phoneAmountText: some View {
    120         let text = amountText
    121         let len = text.count
    122         let fontSize: CGFloat = {
    123             if len <= 6 { return 56 }
    124             if len <= 8 { return 48 }
    125             if len <= 10 { return 40 }
    126             if len <= 12 { return 32 }
    127             if len <= 14 { return 26 }
    128             return 22
    129         }()
    130         return Text(text)
    131             .font(.system(size: fontSize, weight: .bold, design: .default))
    132             .lineLimit(1)
    133             .fixedSize(horizontal: true, vertical: false)
    134     }
    135 
    136     // MARK: - Tablet Amount Pane (horizontal: amount text + currency dropdown)
    137 
    138     private var tabletAmountPane: some View {
    139         HStack(spacing: 12) {
    140             Spacer()
    141 
    142             Text(amountText)
    143                 .font(.system(size: 56, weight: .bold, design: .default))
    144                 .lineLimit(1)
    145                 .fixedSize(horizontal: true, vertical: false)
    146 
    147             currencyDropdown
    148 
    149             Spacer()
    150         }
    151         .frame(maxWidth: .infinity, maxHeight: .infinity)
    152     }
    153 
    154     // MARK: - Currency Dropdown (OutlinedTextField-style)
    155 
    156     private var currencyDropdown: some View {
    157         Menu {
    158             if let currency = configManager.currency {
    159                 Button(currency) {
    160                     selectCurrency(currency)
    161                 }
    162             }
    163         } label: {
    164             HStack(spacing: 4) {
    165                 VStack(alignment: .leading, spacing: 2) {
    166                     Text(S.amountEntryLabel)
    167                         .font(.caption)
    168                         .foregroundColor(.posOnSurfaceVariant)
    169                     Text(selectedCurrency.isEmpty ? "---" : selectedCurrency)
    170                         .font(.body)
    171                         .fontWeight(.medium)
    172                         .foregroundColor(.posOnSurface)
    173                 }
    174                 Image(systemName: "chevron.down")
    175                     .font(.caption)
    176                     .foregroundColor(.posOnSurfaceVariant)
    177             }
    178             .padding(.horizontal, 12)
    179             .padding(.vertical, 8)
    180             .frame(minWidth: 96)
    181             .background(
    182                 RoundedRectangle(cornerRadius: 14)
    183                     .fill(Color.posSurface)
    184             )
    185             .overlay(
    186                 RoundedRectangle(cornerRadius: 14)
    187                     .stroke(Color.posOutlineVariant, lineWidth: 1)
    188             )
    189         }
    190     }
    191 
    192     private func selectCurrency(_ currency: String) {
    193         if selectedCurrency != currency {
    194             selectedCurrency = currency
    195             amount = .zero(currency)
    196             errorMessage = nil
    197         }
    198     }
    199 
    200     // MARK: - Keypad Pane
    201 
    202     private func keypadPane(isCompact: Bool) -> some View {
    203         VStack(spacing: isCompact ? 6 : 8) {
    204             AmountNumpad(
    205                 amount: $amount,
    206                 currency: selectedCurrency.isEmpty ? (configManager.currency ?? "") : selectedCurrency,
    207                 numInputDigits: numInputDigits,
    208                 isCompact: isCompact
    209             )
    210             chargeButton
    211         }
    212     }
    213 
    214     private var chargeButton: some View {
    215         let canCharge = !amount.isZero && !selectedCurrency.isEmpty
    216         return Button(action: createPayment) {
    217             Text(S.amountEntryCreateOrder)
    218                 .fontWeight(.semibold)
    219                 .frame(maxWidth: .infinity)
    220                 .frame(height: 56)
    221                 .background(canCharge ? Color.posPrimary : Color.posSurfaceVariant)
    222                 .foregroundColor(canCharge ? .posOnPrimary : .posOnSurfaceVariant)
    223                 .cornerRadius(12)
    224         }
    225         .disabled(!canCharge)
    226     }
    227 
    228     // MARK: - Create Payment
    229 
    230     private func createPayment() {
    231         guard !amount.isZero else {
    232             errorMessage = S.amountEntryErrorZero
    233             return
    234         }
    235         guard let configuredCurrency = configManager.currency else {
    236             onNavigate(.config)
    237             return
    238         }
    239         if selectedCurrency != configuredCurrency {
    240             errorMessage = S.amountEntryErrorWrongCurrency
    241             return
    242         }
    243 
    244         let product = ConfigProduct(
    245             productId: quickAmountProductId,
    246             description: S.amountEntryProductDescription,
    247             price: amount,
    248             categories: [Int.min],
    249             quantity: 1
    250         )
    251         let order = Order(
    252             id: -1,
    253             currency: configuredCurrency,
    254             availableCategories: [:],
    255             products: [product]
    256         )
    257         paymentManager.createPayment(order: order, includeProducts: false, origin: .amountEntry)
    258         onNavigate(.processPayment)
    259     }
    260 }
    261 
    262 // MARK: - Reusable Numpad
    263 
    264 struct AmountNumpad: View {
    265     @Binding var amount: Amount
    266     let currency: String
    267     let numInputDigits: Int
    268     var isCompact: Bool = false
    269     var onKeyPressed: (() -> Void)? = nil
    270 
    271     private var digitRows: [[String]] {
    272         [["1", "2", "3"], ["4", "5", "6"], ["7", "8", "9"]]
    273     }
    274 
    275     var body: some View {
    276         let rowSpacing: CGFloat = isCompact ? 6 : 8
    277         let digitFontSize: CGFloat = isCompact ? 24 : 28
    278 
    279         VStack(spacing: rowSpacing) {
    280             ForEach(digitRows, id: \.self) { row in
    281                 HStack(spacing: rowSpacing) {
    282                     ForEach(row, id: \.self) { digit in
    283                         AmountKeyButton(text: digit, fontSize: digitFontSize) {
    284                             handleKey(digit)
    285                         }
    286                     }
    287                 }
    288                 .frame(maxHeight: .infinity)
    289             }
    290 
    291             bottomRow(rowSpacing: rowSpacing, digitFontSize: digitFontSize)
    292                 .frame(maxHeight: .infinity)
    293         }
    294     }
    295 
    296     private func bottomRow(rowSpacing: CGFloat, digitFontSize: CGFloat) -> some View {
    297         let clearLabel = S.amountEntryClear
    298         let clearFontSize: CGFloat = {
    299             let len = clearLabel.count
    300             if len >= 14 { return isCompact ? 12 : 14 }
    301             if len >= 10 { return isCompact ? 14 : 16 }
    302             return isCompact ? 16 : 20
    303         }()
    304 
    305         return HStack(spacing: rowSpacing) {
    306             AmountKeyButton(text: clearLabel, fontSize: clearFontSize) {
    307                 handleKey("clear")
    308             }
    309             AmountKeyButton(text: "0", fontSize: digitFontSize) {
    310                 handleKey("0")
    311             }
    312             Button(action: { handleKey("backspace") }) {
    313                 Image(systemName: "delete.backward")
    314                     .font(.system(size: digitFontSize))
    315                     .foregroundColor(.posOnSecondaryContainer)
    316                     .frame(maxWidth: .infinity, maxHeight: .infinity)
    317                     .background(Color.posSecondaryContainer)
    318                     .cornerRadius(12)
    319             }
    320             .accessibilityLabel(S.amountEntryBackspace)
    321         }
    322     }
    323 
    324     private func handleKey(_ key: String) {
    325         onKeyPressed?()
    326         if amount.currency != currency {
    327             amount = .zero(currency)
    328         }
    329         switch key {
    330         case "clear":
    331             amount = .zero(currency)
    332         case "backspace":
    333             amount = amount.removeInputDigit(numInputDigits: numInputDigits)
    334         default:
    335             if let digit = Int(key) {
    336                 if let newAmount = amount.addInputDigit(digit, numInputDigits: numInputDigits) {
    337                     amount = newAmount
    338                 }
    339             }
    340         }
    341     }
    342 }
    343 
    344 struct AmountKeyButton: View {
    345     let text: String
    346     let fontSize: CGFloat
    347     let action: () -> Void
    348 
    349     var body: some View {
    350         Button(action: action) {
    351             Text(text)
    352                 .font(.system(size: fontSize, weight: .semibold))
    353                 .lineLimit(1)
    354                 .minimumScaleFactor(0.7)
    355                 .frame(maxWidth: .infinity, maxHeight: .infinity)
    356                 .padding(.horizontal, 2)
    357                 .background(Color.posSecondaryContainer)
    358                 .foregroundColor(.posOnSecondaryContainer)
    359                 .cornerRadius(12)
    360         }
    361     }
    362 }