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 }