OrderView.swift (23455B)
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 struct OrderView: View { 20 @EnvironmentObject var orderManager: OrderManager 21 @EnvironmentObject var paymentManager: PaymentManager 22 @EnvironmentObject var configManager: ConfigManager 23 let onNavigate: (PosDestination) -> Void 24 var openDrawer: (() -> Void)? 25 26 @State private var showCustomProduct = false 27 @State private var refreshTimer: Timer? 28 @State private var toastMessage: String? 29 @State private var manualReloadTriggered = false 30 31 private var liveOrder: LiveOrder? { orderManager.currentLiveOrder } 32 private var isTablet: Bool { UIDevice.current.userInterfaceIdiom == .pad } 33 34 var body: some View { 35 VStack(spacing: 0) { 36 orderTopBar 37 threeColumnLayout 38 } 39 .navigationBarHidden(true) 40 .onAppear { 41 startInventoryRefresh() 42 if ScreenshotController.showCustomDialog { 43 showCustomProduct = true 44 } 45 } 46 .onDisappear { stopInventoryRefresh() } 47 .sheet(isPresented: $showCustomProduct) { 48 CustomProductDialog(currency: orderManager.currentLiveOrder?.order.currency ?? configManager.currency ?? "", currencySpec: configManager.currencySpec) { product in 49 if let orderId = liveOrder?.id { 50 orderManager.addProduct(orderId: orderId, product: product) 51 } 52 } 53 } 54 .toast(message: $toastMessage) 55 .onChange(of: configManager.isLoading) { loading in 56 if !loading && manualReloadTriggered { 57 manualReloadTriggered = false 58 toastMessage = S.toastReloaded 59 } 60 } 61 } 62 63 private var orderTopBar: some View { 64 PosTopBar( 65 title: liveOrder.map { S.orderLabelTitle("\($0.id)") } ?? "Order", 66 onMenuTap: { openDrawer?() } 67 ) { 68 HStack(spacing: 8) { 69 topBarButton( 70 label: liveOrder?.restartState == .undo ? S.orderUndo : S.orderRestart, 71 enabled: liveOrder?.restartState != .disabled || liveOrder?.order.products.isEmpty == false 72 ) { 73 liveOrder?.restartOrUndo() 74 } 75 76 topBarButton( 77 label: S.orderPrevious, 78 enabled: orderManager.hasPreviousOrder() 79 ) { 80 orderManager.previousOrder() 81 } 82 83 topBarButton( 84 label: S.orderNext, 85 enabled: orderManager.hasNextOrder() 86 ) { 87 orderManager.nextOrder() 88 } 89 90 topBarButton( 91 label: S.menuReload, 92 enabled: true 93 ) { 94 manualReloadTriggered = true 95 toastMessage = S.toastReloading 96 configManager.reloadConfig() 97 } 98 } 99 } 100 } 101 102 private func topBarButton(label: String, enabled: Bool, action: @escaping () -> Void) -> some View { 103 Button(action: action) { 104 Text(label) 105 .font(.subheadline) 106 .fontWeight(.medium) 107 .padding(.vertical, 8) 108 .padding(.horizontal, 14) 109 .frame(minHeight: 40) 110 .background(enabled ? Color.posSecondaryContainer : Color.posSurfaceVariant) 111 .foregroundColor(enabled ? .posOnSecondaryContainer : .posOnSurfaceVariant) 112 .clipShape(RoundedRectangle(cornerRadius: 20)) 113 } 114 .buttonStyle(.plain) 115 .disabled(!enabled) 116 } 117 118 private var threeColumnLayout: some View { 119 GeometryReader { geo in 120 let catWeight: CGFloat = isTablet ? 0.25 : 0.22 121 let prodWeight: CGFloat = isTablet ? 0.50 : 0.43 122 let orderWeight: CGFloat = isTablet ? 0.25 : 0.35 123 let compact = !isTablet 124 125 HStack(spacing: 0) { 126 CategoriesPane( 127 categories: orderManager.categories, 128 onSelect: { orderManager.selectCategory($0) } 129 ) 130 .frame(width: geo.size.width * catWeight) 131 132 Color.posOutlineVariant.frame(width: 1) 133 134 ProductsPane( 135 products: orderManager.products, 136 currencySpec: configManager.currencySpec, 137 compact: compact, 138 onTap: { product in 139 if let orderId = liveOrder?.id { 140 orderManager.addProduct(orderId: orderId, product: product) 141 } 142 } 143 ) 144 .frame(width: geo.size.width * prodWeight) 145 146 Color.posOutlineVariant.frame(width: 1) 147 148 OrderColumnPane( 149 liveOrder: liveOrder, 150 currencySpec: configManager.currencySpec, 151 compact: compact, 152 onSelectLine: { product in liveOrder?.selectOrderLine(product) }, 153 onIncrease: { liveOrder?.increaseSelectedOrderLine() }, 154 onDecrease: { liveOrder?.decreaseSelectedOrderLine() }, 155 onCustom: { showCustomProduct = true }, 156 onComplete: { 157 guard let lo = liveOrder, !lo.order.products.isEmpty else { return } 158 paymentManager.createPayment(order: lo.order) 159 onNavigate(.processPayment) 160 } 161 ) 162 .frame(width: geo.size.width * orderWeight) 163 } 164 .padding(.horizontal, isTablet ? 0 : 8) 165 .padding(.vertical, isTablet ? 0 : 10) 166 } 167 } 168 169 private func startInventoryRefresh() { 170 refreshTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { _ in 171 configManager.refreshInventory() 172 } 173 } 174 175 private func stopInventoryRefresh() { 176 refreshTimer?.invalidate() 177 refreshTimer = nil 178 } 179 } 180 181 struct CategoriesPane: View { 182 let categories: [Category] 183 let onSelect: (Category) -> Void 184 185 var body: some View { 186 ScrollView { 187 LazyVStack(spacing: 6) { 188 ForEach(categories) { category in 189 Button { 190 onSelect(category) 191 } label: { 192 Text(category.localizedName) 193 .font(.subheadline) 194 .fontWeight(.semibold) 195 .frame(maxWidth: .infinity) 196 .padding(.vertical, 10) 197 .padding(.horizontal, 8) 198 .background(category.selected ? Color.posSecondary : Color.posSecondaryContainer) 199 .foregroundColor(category.selected ? .posOnSecondary : .posOnSecondaryContainer) 200 .clipShape(Capsule()) 201 .overlay(Capsule().stroke(Color.posOutlineVariant, lineWidth: 1)) 202 } 203 } 204 } 205 .padding(8) 206 } 207 } 208 } 209 210 struct ProductsPane: View { 211 let products: [ConfigProduct] 212 var currencySpec: CurrencySpecification? 213 var compact: Bool = false 214 let onTap: (ConfigProduct) -> Void 215 216 var body: some View { 217 GeometryReader { geo in 218 let spacing: CGFloat = compact ? 6 : 8 219 let minTileWidth: CGFloat = compact ? 96 : 150 220 let columns = max(1, Int((geo.size.width + spacing) / (minTileWidth + spacing))) 221 let rows = products.chunked(into: columns) 222 223 ScrollView { 224 VStack(spacing: spacing) { 225 ForEach(Array(rows.enumerated()), id: \.offset) { _, row in 226 let rowHasImage = row.contains { $0.image != nil && !$0.image!.isEmpty } 227 HStack(spacing: spacing) { 228 ForEach(row) { product in 229 ProductCard( 230 product: product, 231 currencySpec: currencySpec, 232 compact: compact, 233 rowHasImage: rowHasImage 234 ) { 235 onTap(product) 236 } 237 } 238 if row.count < columns { 239 ForEach(0..<(columns - row.count), id: \.self) { _ in 240 Color.clear.frame(maxWidth: .infinity) 241 } 242 } 243 } 244 } 245 } 246 .padding(compact ? 4 : 8) 247 .padding(.top, compact ? 8 : 12) 248 } 249 } 250 } 251 } 252 253 struct ProductCard: View { 254 let product: ConfigProduct 255 var currencySpec: CurrencySpecification? 256 var compact: Bool = false 257 var rowHasImage: Bool = false 258 let onTap: () -> Void 259 260 private var imageSize: CGFloat { compact ? 40 : 64 } 261 262 private var unavailableReason: String? { 263 guard !product.availableToSell else { return nil } 264 if product.currencyMismatch { return S.productWrongCurrency } 265 if product.remainingStock == 0 { return S.productOutOfStock } 266 return S.productUnavailable 267 } 268 269 var body: some View { 270 Button(action: onTap) { 271 VStack(spacing: compact ? 3 : 4) { 272 if let uiImage = product.decodedImage { 273 Image(uiImage: uiImage) 274 .resizable() 275 .scaledToFit() 276 .frame(width: imageSize, height: imageSize) 277 .frame(maxWidth: .infinity) 278 } else if rowHasImage { 279 Spacer() 280 .frame(height: imageSize) 281 .frame(maxWidth: .infinity) 282 } 283 284 Text(product.displayName) 285 .font(compact ? .caption : .subheadline) 286 .fontWeight(.bold) 287 .multilineTextAlignment(.center) 288 .lineLimit(2) 289 .frame(maxWidth: .infinity) 290 291 if let desc = product.displayDescription { 292 Text(desc) 293 .font(compact ? .caption2 : .caption) 294 .multilineTextAlignment(.center) 295 .lineLimit(2) 296 .frame(maxWidth: .infinity) 297 } 298 299 Spacer(minLength: 0) 300 301 Text(product.price.readableAmount(spec: currencySpec)) 302 .font(compact ? .caption : .subheadline) 303 .multilineTextAlignment(.center) 304 .frame(maxWidth: .infinity) 305 306 if let reason = unavailableReason { 307 Text(reason) 308 .font(compact ? .caption2 : .caption) 309 .fontWeight(.bold) 310 .foregroundColor(.posError) 311 .multilineTextAlignment(.center) 312 .frame(maxWidth: .infinity) 313 } 314 } 315 .padding(compact ? 6 : 8) 316 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) 317 .background(product.availableToSell ? Color.posSurface : Color.posSurfaceVariant) 318 .cornerRadius(10) 319 .overlay( 320 RoundedRectangle(cornerRadius: 10) 321 .stroke( 322 product.availableToSell ? Color.posOutlineVariant : Color.posError.opacity(0.4), 323 lineWidth: 1 324 ) 325 ) 326 .shadow(color: .black.opacity(0.04), radius: 1, y: 1) 327 } 328 .buttonStyle(.plain) 329 .disabled(!product.availableToSell) 330 } 331 } 332 333 struct OrderColumnPane: View { 334 let liveOrder: LiveOrder? 335 var currencySpec: CurrencySpecification? 336 var compact: Bool = false 337 let onSelectLine: (ConfigProduct?) -> Void 338 let onIncrease: () -> Void 339 let onDecrease: () -> Void 340 let onCustom: () -> Void 341 let onComplete: () -> Void 342 343 var body: some View { 344 VStack(spacing: 0) { 345 if let lo = liveOrder { 346 ScrollView { 347 LazyVStack(spacing: 0) { 348 ForEach(lo.order.products) { product in 349 OrderRow( 350 product: product, 351 currencySpec: currencySpec, 352 isSelected: lo.selectedProductKey == product.id, 353 compact: compact 354 ) { 355 onSelectLine(product) 356 } 357 Color.posOutlineVariant.frame(height: 1) 358 } 359 } 360 } 361 362 Color.posOutlineVariant.frame(height: 1) 363 364 OrderActionBar( 365 modifyAllowed: lo.modifyOrderAllowed, 366 increaseAllowed: lo.increaseOrderAllowed, 367 hasProducts: !lo.order.products.isEmpty, 368 totalAmount: lo.order.total.readableAmount(spec: currencySpec), 369 compact: compact, 370 onIncrease: onIncrease, 371 onDecrease: onDecrease, 372 onCustom: onCustom, 373 onComplete: onComplete 374 ) 375 } 376 } 377 } 378 } 379 380 struct OrderRow: View { 381 let product: ConfigProduct 382 var currencySpec: CurrencySpecification? 383 let isSelected: Bool 384 var compact: Bool = false 385 let onTap: () -> Void 386 387 private var imageSize: CGFloat { compact ? 28 : 36 } 388 389 var body: some View { 390 Button(action: onTap) { 391 HStack(spacing: compact ? 6 : 8) { 392 Text("\(product.quantity)") 393 .font(compact ? .caption : .subheadline) 394 .fontWeight(.semibold) 395 .frame(width: 24, alignment: .trailing) 396 397 if let uiImage = product.decodedImage { 398 Image(uiImage: uiImage) 399 .resizable() 400 .scaledToFit() 401 .frame(width: imageSize, height: imageSize) 402 } else { 403 Color.clear.frame(width: imageSize, height: imageSize) 404 } 405 406 VStack(alignment: .leading, spacing: 2) { 407 Text(product.displayName) 408 .font(compact ? .caption : .subheadline) 409 .lineLimit(2) 410 if let desc = product.displayDescription { 411 Text(desc) 412 .font(compact ? .caption2 : .caption) 413 .foregroundColor(.posOnSurfaceVariant) 414 .lineLimit(1) 415 } 416 } 417 418 Spacer() 419 420 Text((product.price * product.quantity).readableAmount(spec: currencySpec)) 421 .font(compact ? .caption : .subheadline) 422 .fontWeight(.medium) 423 } 424 .padding(.horizontal, compact ? 6 : 10) 425 .padding(.vertical, compact ? 4 : 8) 426 .contentShape(Rectangle()) 427 .background(isSelected ? Color.posSecondaryContainer : Color.clear) 428 } 429 .buttonStyle(.plain) 430 } 431 } 432 433 struct OrderActionBar: View { 434 let modifyAllowed: Bool 435 let increaseAllowed: Bool 436 let hasProducts: Bool 437 var totalAmount: String = "" 438 var compact: Bool = false 439 let onIncrease: () -> Void 440 let onDecrease: () -> Void 441 let onCustom: () -> Void 442 let onComplete: () -> Void 443 444 var body: some View { 445 VStack(spacing: compact ? 8 : 12) { 446 HStack(spacing: compact ? 8 : 12) { 447 orderControlButton(label: "+1", enabled: modifyAllowed && increaseAllowed, action: onIncrease) 448 orderControlButton(label: "-1", enabled: modifyAllowed, action: onDecrease) 449 Button(action: onCustom) { 450 Image(systemName: "square.grid.3x3") 451 .font(compact ? .body : .title3) 452 .frame(maxWidth: .infinity) 453 .frame(height: 44) 454 .background(Color.posPrimaryContainer) 455 .foregroundColor(.posOnPrimaryContainer) 456 .clipShape(RoundedRectangle(cornerRadius: 20)) 457 } 458 .buttonStyle(.plain) 459 .accessibilityLabel(S.orderCustom) 460 } 461 462 Button(action: onComplete) { 463 Text(hasProducts ? S.orderCompleteWithAmount(totalAmount) : S.orderComplete) 464 .font(compact ? .body : .title3) 465 .fontWeight(.bold) 466 .multilineTextAlignment(.center) 467 .frame(maxWidth: .infinity) 468 .frame(height: compact ? 72 : 96) 469 .background(hasProducts ? Color.posPrimary : Color.posSurfaceVariant) 470 .foregroundColor(hasProducts ? .posOnPrimary : .posOnSurfaceVariant) 471 .clipShape(RoundedRectangle(cornerRadius: 24)) 472 } 473 .buttonStyle(.plain) 474 .disabled(!hasProducts) 475 } 476 .padding(12) 477 } 478 479 private func orderControlButton(label: String, enabled: Bool, action: @escaping () -> Void) -> some View { 480 Button(action: action) { 481 Text(label) 482 .font(compact ? .body : .title3) 483 .fontWeight(.medium) 484 .frame(maxWidth: .infinity) 485 .frame(height: 44) 486 .background(enabled ? Color.posPrimaryContainer : Color.posSurfaceVariant) 487 .foregroundColor(enabled ? .posOnPrimaryContainer : .posOnSurfaceVariant) 488 .clipShape(RoundedRectangle(cornerRadius: 20)) 489 } 490 .buttonStyle(.plain) 491 .disabled(!enabled) 492 } 493 } 494 495 private extension Array { 496 func chunked(into size: Int) -> [[Element]] { 497 stride(from: 0, to: count, by: size).map { 498 Array(self[$0..<Swift.min($0 + size, count)]) 499 } 500 } 501 } 502 503 struct CustomProductDialog: View { 504 let currency: String 505 let currencySpec: CurrencySpecification? 506 let onAdd: (ConfigProduct) -> Void 507 @Environment(\.dismiss) private var dismiss 508 @State private var name = S.orderCustomProductDefault 509 @State private var amount: Amount = .zero("") 510 @FocusState private var nameFieldFocused: Bool 511 512 private var numInputDigits: Int { 513 currencySpec?.numFractionalInputDigits ?? 2 514 } 515 516 var body: some View { 517 NavigationStack { 518 GeometryReader { geo in 519 ScrollView { 520 VStack(spacing: 12) { 521 VStack(alignment: .leading, spacing: 4) { 522 Text(S.orderCustomProduct) 523 .font(.caption) 524 .foregroundColor(.posOnSurfaceVariant) 525 TextField(S.orderCustomProduct, text: $name) 526 .focused($nameFieldFocused) 527 .submitLabel(.done) 528 .onSubmit { nameFieldFocused = false } 529 .padding(10) 530 .overlay( 531 RoundedRectangle(cornerRadius: 8) 532 .stroke(Color.posOutline, lineWidth: 1) 533 ) 534 } 535 .padding(.horizontal, 20) 536 .padding(.top, 8) 537 538 HStack { 539 Spacer() 540 Text(amount.amountStr(numDigits: numInputDigits)) 541 .font(.system(size: 40, weight: .bold)) 542 .lineLimit(1) 543 .fixedSize(horizontal: true, vertical: false) 544 Text(currency) 545 .font(.title3) 546 .foregroundColor(.posOnSurfaceVariant) 547 Spacer() 548 } 549 .padding(.vertical, 8) 550 .onTapGesture { nameFieldFocused = false } 551 552 AmountNumpad( 553 amount: $amount, 554 currency: currency, 555 numInputDigits: numInputDigits, 556 isCompact: true, 557 onKeyPressed: { nameFieldFocused = false } 558 ) 559 .frame(height: geo.size.height * 0.55) 560 .padding(.horizontal, 20) 561 562 Button { 563 guard !name.isEmpty, !amount.isZero else { return } 564 let product = ConfigProduct( 565 description: name, 566 price: amount, 567 categories: [Int.min], 568 quantity: 1 569 ) 570 onAdd(product) 571 dismiss() 572 } label: { 573 Text(S.orderCustomAdd) 574 .fontWeight(.semibold) 575 .frame(maxWidth: .infinity) 576 .frame(height: 50) 577 .background(!name.isEmpty && !amount.isZero ? Color.posPrimary : Color.posSurfaceVariant) 578 .foregroundColor(!name.isEmpty && !amount.isZero ? .posOnPrimary : .posOnSurfaceVariant) 579 .cornerRadius(12) 580 } 581 .disabled(name.isEmpty || amount.isZero) 582 .padding(.horizontal, 20) 583 .padding(.bottom, 12) 584 } 585 } 586 .scrollDismissesKeyboard(.interactively) 587 } 588 .navigationTitle(S.orderCustom) 589 .navigationBarTitleDisplayMode(.inline) 590 .toolbar { 591 ToolbarItem(placement: .cancellationAction) { 592 Button(S.refundAbort) { dismiss() } 593 } 594 } 595 .onAppear { 596 amount = .zero(currency) 597 } 598 } 599 } 600 }