taler-ios

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

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 }