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 }