taler-ios

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

ContentView.swift (15619B)


      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 enum PosDestination: Hashable {
     20     case amountEntry
     21     case order
     22     case history
     23     case settings
     24     case config
     25     case processPayment
     26     case paymentSuccess
     27     case refund(OrderHistoryEntry)
     28     case refundUri
     29 
     30     func hash(into hasher: inout Hasher) {
     31         switch self {
     32         case .amountEntry: hasher.combine("amountEntry")
     33         case .order: hasher.combine("order")
     34         case .history: hasher.combine("history")
     35         case .settings: hasher.combine("settings")
     36         case .config: hasher.combine("config")
     37         case .processPayment: hasher.combine("processPayment")
     38         case .paymentSuccess: hasher.combine("paymentSuccess")
     39         case .refund(let item): hasher.combine("refund"); hasher.combine(item.orderId)
     40         case .refundUri: hasher.combine("refundUri")
     41         }
     42     }
     43 
     44     static func == (lhs: PosDestination, rhs: PosDestination) -> Bool {
     45         switch (lhs, rhs) {
     46         case (.amountEntry, .amountEntry), (.order, .order), (.history, .history),
     47              (.settings, .settings), (.config, .config), (.processPayment, .processPayment),
     48              (.paymentSuccess, .paymentSuccess), (.refundUri, .refundUri):
     49             return true
     50         case (.refund(let a), .refund(let b)):
     51             return a.orderId == b.orderId
     52         default: return false
     53         }
     54     }
     55 
     56     var title: String {
     57         switch self {
     58         case .amountEntry: return S.menuAmountEntry
     59         case .order: return S.menuOrder
     60         case .history: return S.menuHistory
     61         case .settings: return S.menuSettings
     62         case .config: return S.configLabel
     63         case .processPayment: return S.paymentProcessLabel
     64         case .paymentSuccess: return S.paymentReceived
     65         case .refund: return S.historyRefund
     66         case .refundUri: return S.historyRefund
     67         }
     68     }
     69 
     70     var icon: String {
     71         switch self {
     72         case .amountEntry: return "circle.grid.3x3"
     73         case .order: return "cart"
     74         case .history: return "clock"
     75         case .settings: return "gearshape"
     76         default: return "circle"
     77         }
     78     }
     79 
     80     var isMainDestination: Bool {
     81         switch self {
     82         case .amountEntry, .order, .history, .settings: return true
     83         default: return false
     84         }
     85     }
     86 
     87     var savedKey: String {
     88         switch self {
     89         case .amountEntry: return "amountEntry"
     90         case .order: return "order"
     91         case .history: return "history"
     92         case .settings: return "settings"
     93         default: return ""
     94         }
     95     }
     96 
     97     static func from(savedKey: String) -> PosDestination? {
     98         switch savedKey {
     99         case "amountEntry": return .amountEntry
    100         case "order": return .order
    101         case "history": return .history
    102         case "settings": return .settings
    103         default: return nil
    104         }
    105     }
    106 }
    107 
    108 struct NavigationDrawer: View {
    109     let currentRoot: PosDestination
    110     let destinations: [PosDestination]
    111     let onSelect: (PosDestination) -> Void
    112 
    113     var body: some View {
    114         VStack(alignment: .leading, spacing: 6) {
    115             Image("talerpos-logo")
    116                 .resizable()
    117                 .scaledToFit()
    118                 .frame(maxWidth: .infinity)
    119                 .frame(height: 56)
    120                 .padding(.horizontal, 8)
    121                 .padding(.vertical, 8)
    122 
    123             ForEach(destinations, id: \.self) { dest in
    124                 Button {
    125                     onSelect(dest)
    126                 } label: {
    127                     HStack(spacing: 12) {
    128                         Image(systemName: dest.icon)
    129                             .font(.body)
    130                             .frame(width: 24)
    131                         Text(dest.title)
    132                             .font(.body)
    133                             .fontWeight(.semibold)
    134                     }
    135                     .frame(maxWidth: .infinity, alignment: .leading)
    136                     .padding(.vertical, 12)
    137                     .padding(.horizontal, 16)
    138                     .background(
    139                         currentRoot == dest
    140                             ? Color.posSecondaryContainer
    141                             : Color.clear
    142                     )
    143                     .foregroundColor(
    144                         currentRoot == dest
    145                             ? .posOnSecondaryContainer
    146                             : .posOnSurface
    147                     )
    148                     .clipShape(RoundedRectangle(cornerRadius: 28))
    149                 }
    150                 .buttonStyle(.plain)
    151             }
    152 
    153             Spacer()
    154         }
    155         .padding(12)
    156         .frame(width: 280)
    157         .frame(maxHeight: .infinity)
    158         .background(Color.posSurface)
    159     }
    160 }
    161 
    162 struct PosTopBar<Actions: View>: View {
    163     let title: String
    164     let onMenuTap: () -> Void
    165     let actions: Actions
    166 
    167     init(title: String, onMenuTap: @escaping () -> Void, @ViewBuilder actions: () -> Actions) {
    168         self.title = title
    169         self.onMenuTap = onMenuTap
    170         self.actions = actions()
    171     }
    172 
    173     var body: some View {
    174         VStack(spacing: 0) {
    175             HStack(spacing: 8) {
    176                 Button {
    177                     onMenuTap()
    178                 } label: {
    179                     Image(systemName: "line.3.horizontal")
    180                         .font(.title2)
    181                         .frame(width: 44, height: 44)
    182                         .contentShape(Rectangle())
    183                         .foregroundColor(.posOnSurface)
    184                 }
    185                 .buttonStyle(.plain)
    186 
    187                 Text(title)
    188                     .font(.title3)
    189                     .fontWeight(.medium)
    190                     .lineLimit(1)
    191                     .foregroundColor(.posOnSurface)
    192 
    193                 Spacer(minLength: 0)
    194 
    195                 actions
    196                     .fixedSize(horizontal: true, vertical: false)
    197             }
    198             .padding(.horizontal, 8)
    199             .padding(.vertical, 8)
    200         }
    201         .background(Color.posSurface)
    202         .shadow(color: .black.opacity(0.12), radius: 2, y: 1)
    203     }
    204 }
    205 
    206 extension PosTopBar where Actions == EmptyView {
    207     init(title: String, onMenuTap: @escaping () -> Void) {
    208         self.title = title
    209         self.onMenuTap = onMenuTap
    210         self.actions = EmptyView()
    211     }
    212 }
    213 
    214 struct ContentView: View {
    215     @EnvironmentObject var configManager: ConfigManager
    216     @EnvironmentObject var orderManager: OrderManager
    217     @EnvironmentObject var paymentManager: PaymentManager
    218     @EnvironmentObject var historyManager: HistoryManager
    219     @EnvironmentObject var refundManager: RefundManager
    220     @State private var navigationPath = NavigationPath()
    221     @State private var currentRoot: PosDestination = .amountEntry
    222     @State var drawerOpen = false
    223     @AppStorage("pos_current_root") private var savedRoot: String = ""
    224 
    225     private var mainDestinations: [PosDestination] {
    226         let preferred: PosDestination = configManager.initialOrderScreen.destination
    227         var items: [PosDestination] = [.amountEntry, .order, .history, .settings]
    228         items.removeAll { $0 == preferred }
    229         items.insert(preferred, at: 0)
    230         return items
    231     }
    232 
    233     private var appState: AppState {
    234         if !configManager.isValid {
    235             return .needsConfig
    236         }
    237         if configManager.isConfigured {
    238             return .configured
    239         }
    240         return .fetching
    241     }
    242 
    243     private enum AppState {
    244         case needsConfig
    245         case fetching
    246         case configured
    247     }
    248 
    249     var body: some View {
    250         Group {
    251             switch appState {
    252             case .configured:
    253                 mainView
    254             case .fetching:
    255                 NavigationStack {
    256                     configFetcherView
    257                 }
    258             case .needsConfig:
    259                 NavigationStack {
    260                     ConfigView(onConfigured: {
    261                         currentRoot = configManager.initialOrderScreen.destination
    262                     })
    263                 }
    264             }
    265         }
    266         .onAppear {
    267             if let scenario = ScreenshotController.activeScenario {
    268                 switch scenario {
    269                 case .refund:
    270                     currentRoot = .history
    271                     DispatchQueue.main.async {
    272                         if let item = refundManager.toBeRefunded {
    273                             navigationPath.append(PosDestination.refund(item))
    274                         }
    275                     }
    276                 case .refundQr:
    277                     currentRoot = .history
    278                     DispatchQueue.main.async {
    279                         navigationPath.append(PosDestination.refundUri)
    280                     }
    281                 case .navigation:
    282                     currentRoot = .order
    283                     DispatchQueue.main.async {
    284                         drawerOpen = true
    285                     }
    286                 default:
    287                     let dest = scenario.destination
    288                     if dest.isMainDestination {
    289                         currentRoot = dest
    290                     } else {
    291                         currentRoot = .order
    292                         DispatchQueue.main.async {
    293                             navigationPath.append(dest)
    294                         }
    295                     }
    296                 }
    297             } else if let restored = PosDestination.from(savedKey: savedRoot), restored.isMainDestination {
    298                 currentRoot = restored
    299             } else {
    300                 currentRoot = configManager.initialOrderScreen.destination
    301             }
    302         }
    303         .onChange(of: currentRoot) { newRoot in
    304             savedRoot = newRoot.savedKey
    305         }
    306         .alert(S.sessionExpiredToast, isPresented: $configManager.sessionExpired) {
    307             Button(S.configOk) {
    308                 configManager.sessionExpired = false
    309             }
    310         } message: {
    311             Text(S.sessionExpiredToast)
    312         }
    313         .onChange(of: refundManager.refundResult) { result in
    314             if case .success = result {
    315                 navigateTo(.refundUri)
    316             }
    317         }
    318         .onChange(of: refundManager.refundReceived) { received in
    319             if received {
    320                 navigationPath.removeLast(navigationPath.count)
    321                 currentRoot = .history
    322             }
    323         }
    324         .onChange(of: paymentManager.payment?.paid) { paid in
    325             guard paid == true else { return }
    326             if let order = paymentManager.payment?.order {
    327                 orderManager.onOrderPaid(order.id)
    328             }
    329             historyManager.fetchHistory()
    330             navigationPath.removeLast(navigationPath.count)
    331             navigateTo(.paymentSuccess)
    332         }
    333         .onChange(of: configManager.isConfigured) { configured in
    334             if !configured {
    335                 navigationPath.removeLast(navigationPath.count)
    336             }
    337         }
    338         .onChange(of: configManager.isValid) { valid in
    339             if !valid {
    340                 navigationPath.removeLast(navigationPath.count)
    341             }
    342         }
    343     }
    344 
    345     private var configFetcherView: some View {
    346         VStack(spacing: 16) {
    347             Spacer()
    348             ProgressView()
    349                 .scaleEffect(1.5)
    350             Text(S.configFetching)
    351                 .font(.title3)
    352             Spacer()
    353         }
    354         .frame(maxWidth: .infinity)
    355         .navigationTitle(S.configLabel)
    356         .navigationBarTitleDisplayMode(.inline)
    357         .onAppear {
    358             if configManager.savePassword {
    359                 configManager.fetchConfig(
    360                     url: configManager.merchantUrl,
    361                     token: configManager.accessToken,
    362                     save: false
    363                 )
    364             }
    365         }
    366         .onChange(of: configManager.configResult) { result in
    367             if case .success = result {
    368                 currentRoot = configManager.initialOrderScreen.destination
    369             }
    370         }
    371     }
    372 
    373     private var mainView: some View {
    374         ZStack(alignment: .leading) {
    375             NavigationStack(path: $navigationPath) {
    376                 rootView(for: currentRoot)
    377                     .id(currentRoot)
    378                     .navigationDestination(for: PosDestination.self) { destination in
    379                         destinationView(for: destination)
    380                     }
    381             }
    382 
    383             if drawerOpen {
    384                 Color.black.opacity(0.3)
    385                     .ignoresSafeArea()
    386                     .onTapGesture {
    387                         withAnimation(.easeInOut(duration: 0.25)) { drawerOpen = false }
    388                     }
    389                     .transition(.opacity)
    390                     .zIndex(1)
    391 
    392                 NavigationDrawer(
    393                     currentRoot: currentRoot,
    394                     destinations: mainDestinations,
    395                     onSelect: { dest in
    396                         withAnimation(.easeInOut(duration: 0.25)) { drawerOpen = false }
    397                         currentRoot = dest
    398                         navigationPath.removeLast(navigationPath.count)
    399                     }
    400                 )
    401                 .transition(.move(edge: .leading))
    402                 .zIndex(2)
    403             }
    404         }
    405     }
    406 
    407     private func openDrawerAnimated() {
    408         withAnimation(.easeInOut(duration: 0.25)) { drawerOpen = true }
    409     }
    410 
    411     @ViewBuilder
    412     private func rootView(for destination: PosDestination) -> some View {
    413         switch destination {
    414         case .amountEntry:
    415             AmountEntryView(onNavigate: navigateTo, openDrawer: openDrawerAnimated)
    416         case .order:
    417             OrderView(onNavigate: navigateTo, openDrawer: openDrawerAnimated)
    418         case .history:
    419             HistoryView(onNavigate: navigateTo, openDrawer: openDrawerAnimated)
    420         case .settings:
    421             SettingsView(onNavigate: navigateTo, openDrawer: openDrawerAnimated)
    422         default:
    423             OrderView(onNavigate: navigateTo, openDrawer: openDrawerAnimated)
    424         }
    425     }
    426 
    427     @ViewBuilder
    428     private func destinationView(for destination: PosDestination) -> some View {
    429         switch destination {
    430         case .processPayment:
    431             ProcessPaymentView(onNavigate: navigateTo)
    432         case .paymentSuccess:
    433             PaymentSuccessView(onNavigate: navigateTo)
    434         case .refund(let item):
    435             RefundFormView(item: item)
    436         case .refundUri:
    437             RefundUriView()
    438         case .config:
    439             ConfigView(onConfigured: {
    440                 navigationPath.removeLast(navigationPath.count)
    441                 currentRoot = configManager.initialOrderScreen.destination
    442             })
    443         default:
    444             rootView(for: destination)
    445         }
    446     }
    447 
    448     func navigateTo(_ destination: PosDestination) {
    449         if destination.isMainDestination {
    450             currentRoot = destination
    451             navigationPath.removeLast(navigationPath.count)
    452         } else {
    453             navigationPath.append(destination)
    454         }
    455     }
    456 
    457     func navigateBack() {
    458         if !navigationPath.isEmpty {
    459             navigationPath.removeLast()
    460         }
    461     }
    462 }