ScreenshotController.swift (16834B)
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 Foundation 18 19 enum ScreenshotScenario: String, CaseIterable { 20 case login = "login" 21 case mfaSelect = "mfa-select" 22 case mfaCode = "mfa-code" 23 case amountEntry = "amount-entry" 24 case order = "order" 25 case orderCustom = "order-custom" 26 case orderCustomAdded = "order-custom-added" 27 case payment = "payment" 28 case paymentSuccess = "payment-success" 29 case history = "history" 30 case refund = "refund" 31 case refundQr = "refund-qr" 32 case navigation = "navigation" 33 case settings = "settings" 34 35 var destination: PosDestination { 36 switch self { 37 case .login, .mfaSelect, .mfaCode: return .config 38 case .amountEntry: return .amountEntry 39 case .order, .orderCustom, .orderCustomAdded: return .order 40 case .payment: return .processPayment 41 case .paymentSuccess: return .paymentSuccess 42 case .history: return .history 43 case .refund: return .history 44 case .refundQr: return .history 45 case .navigation: return .order 46 case .settings: return .settings 47 } 48 } 49 50 var isConfigScreen: Bool { 51 switch self { 52 case .login, .mfaSelect, .mfaCode: return true 53 default: return false 54 } 55 } 56 } 57 58 @MainActor 59 enum ScreenshotController { 60 private static let launchArgument = "SCREENSHOT_SCENARIO" 61 private static let localeArgument = "SCREENSHOT_LOCALE" 62 private static let currency = "CHF" 63 64 static var activeScenario: ScreenshotScenario? { 65 guard let value = ProcessInfo.processInfo.environment[launchArgument] 66 ?? parseLaunchArgument(launchArgument) else { return nil } 67 return ScreenshotScenario(rawValue: value) 68 } 69 70 static var isActive: Bool { activeScenario != nil } 71 static var showCustomDialog: Bool { activeScenario == .orderCustom } 72 static var showDrawer: Bool { activeScenario == .navigation } 73 static var showMfaSelect: Bool { activeScenario == .mfaSelect } 74 static var showMfaCode: Bool { activeScenario == .mfaCode } 75 76 static var mfaMockChallenges: [MfaChallenge] { 77 [ 78 MfaChallenge(challengeId: "ch-sms-1", tanChannel: .sms, tanInfo: "+41 79 *** ** 42"), 79 MfaChallenge(challengeId: "ch-email-1", tanChannel: .email, tanInfo: "m***@example.com"), 80 ] 81 } 82 83 static var mfaMockTanChallenge: MfaChallenge { 84 MfaChallenge(challengeId: "ch-sms-1", tanChannel: .sms, tanInfo: "+41 79 *** ** 42") 85 } 86 87 static func applyLocaleIfNeeded() { 88 guard let tag = ProcessInfo.processInfo.environment[localeArgument] 89 ?? parseLaunchArgument(localeArgument), 90 !tag.isEmpty else { return } 91 LanguageManager.shared.setLanguage(tag) 92 } 93 94 private static func parseLaunchArgument(_ key: String) -> String? { 95 let args = ProcessInfo.processInfo.arguments 96 guard let idx = args.firstIndex(of: "-\(key)"), 97 idx + 1 < args.count else { return nil } 98 return args[idx + 1] 99 } 100 101 static func applyFixture( 102 configManager: ConfigManager, 103 orderManager: OrderManager, 104 paymentManager: PaymentManager, 105 historyManager: HistoryManager, 106 refundManager: RefundManager, 107 scenario: ScreenshotScenario 108 ) { 109 if scenario.isConfigScreen { 110 configManager.merchantUrl = "backend.demo.taler.net" 111 return 112 } 113 114 let fixture = buildFixture() 115 configManager.merchantUrl = "https://merchant.example.invalid/instances/demo" 116 configManager.accessToken = "screenshot-fixture" 117 configManager.merchantConfig = MerchantConfig( 118 baseUrl: "https://merchant.example.invalid/instances/demo", 119 apiKey: "" 120 ) 121 configManager.currency = currency 122 123 _ = orderManager.onConfigurationReceived( 124 posConfig: fixture, 125 currency: currency, 126 currencySpec: nil 127 ) 128 129 switch scenario { 130 case .order, .orderCustom, .orderCustomAdded, .navigation: 131 seedOrder(orderManager: orderManager, productIds: [ 132 "lemonade", "lemonade", "lemonade", "lemonade", "lemonade", 133 "wrap", "wrap", 134 "chips", 135 "salad", "salad", 136 ]) 137 default: 138 seedOrder(orderManager: orderManager, productIds: ["coffee", "croissant", "sandwich", "coffee"]) 139 } 140 141 switch scenario { 142 case .login, .mfaSelect, .mfaCode: 143 break 144 case .amountEntry, .order, .navigation, .settings: 145 break 146 case .orderCustom: 147 break 148 case .orderCustomAdded: 149 if let liveOrder = orderManager.currentLiveOrder { 150 let customProduct = ConfigProduct( 151 description: S.orderCustomProductDefault, 152 price: Amount(currency: currency, value: 2, fraction: 73000000), 153 categories: [Int.min], 154 quantity: 1 155 ) 156 orderManager.addProduct(orderId: liveOrder.id, product: customProduct) 157 } 158 case .payment: 159 if let liveOrder = orderManager.currentLiveOrder { 160 paymentManager.payment = Payment( 161 order: liveOrder.order, 162 summary: liveOrder.order.summary, 163 currency: currency, 164 origin: .inventory, 165 orderId: "2026-ORD-1042", 166 talerPayUri: "taler://pay/example.invalid/2026-ORD-1042/ZXCVBNM123456" 167 ) 168 } 169 case .paymentSuccess: 170 if let liveOrder = orderManager.currentLiveOrder { 171 paymentManager.payment = Payment( 172 order: liveOrder.order, 173 summary: liveOrder.order.summary, 174 currency: currency, 175 origin: .inventory, 176 orderId: "2026-ORD-1042", 177 paid: true 178 ) 179 } 180 case .history: 181 seedHistory(historyManager: historyManager) 182 case .refund: 183 let item = buildRefundItem() 184 refundManager.startRefund(item: item) 185 case .refundQr: 186 let item = buildRefundItem() 187 refundManager.refundResult = .success( 188 refundUri: "taler://refund/example.invalid/2026-ORD-1047/REFUND-ABC123", 189 item: item, 190 amount: Amount(currency: currency, value: 18, fraction: 30000000), 191 reason: S.orderCustomProductDefault 192 ) 193 } 194 } 195 196 private static func seedOrder(orderManager: OrderManager, productIds: [String]) { 197 guard let liveOrder = orderManager.currentLiveOrder else { return } 198 for pid in productIds { 199 if let product = orderManager.products.first(where: { $0.productId == pid }) { 200 liveOrder.addProduct(product) 201 } 202 } 203 } 204 205 private static func seedHistory(historyManager: HistoryManager) { 206 let now = UInt64(Date().timeIntervalSince1970) 207 historyManager.items = [ 208 OrderHistoryEntry( 209 orderId: "2026-ORD-1048", 210 rowId: 48, 211 timestamp: Timestamp(seconds: now - 60), 212 amount: Amount(currency: currency, value: 12, fraction: 40000000), 213 summary: "Chicken Wrap + Lemonade", 214 paid: false, 215 refundable: false 216 ), 217 OrderHistoryEntry( 218 orderId: "2026-ORD-1047", 219 rowId: 47, 220 timestamp: Timestamp(seconds: now - 300), 221 amount: Amount(currency: currency, value: 18, fraction: 30000000), 222 summary: "Veggie Sandwich + House Coffee", 223 paid: true, 224 refundable: true 225 ), 226 OrderHistoryEntry( 227 orderId: "2026-ORD-1046", 228 rowId: 46, 229 timestamp: Timestamp(seconds: now - 900), 230 amount: Amount(currency: currency, value: 6, fraction: 70000000), 231 summary: "Butter Croissant + Herbal Tea", 232 paid: true, 233 refundable: true, 234 pendingRefundAmount: Amount(currency: currency, value: 6, fraction: 70000000), 235 refundPending: true 236 ), 237 OrderHistoryEntry( 238 orderId: "2026-ORD-1045", 239 rowId: 45, 240 timestamp: Timestamp(seconds: now - 1800), 241 amount: Amount(currency: currency, value: 3, fraction: 80000000), 242 summary: "House Coffee", 243 paid: true, 244 refundable: false, 245 refunded: true, 246 refundAmount: Amount(currency: currency, value: 3, fraction: 80000000) 247 ), 248 ] 249 } 250 251 private static func buildRefundItem() -> OrderHistoryEntry { 252 OrderHistoryEntry( 253 orderId: "2026-ORD-1047", 254 rowId: 47, 255 timestamp: Timestamp(seconds: UInt64(Date().timeIntervalSince1970)), 256 amount: Amount(currency: currency, value: 18, fraction: 30000000), 257 summary: "Veggie Sandwich + House Coffee", 258 paid: true, 259 refundable: true 260 ) 261 } 262 263 private static func imageDataUri(_ name: String) -> String? { 264 guard let url = Bundle.main.url(forResource: name, withExtension: "png"), 265 let data = try? Data(contentsOf: url) else { return nil } 266 return "data:image/png;base64,\(data.base64EncodedString())" 267 } 268 269 private static func buildFixture() -> PosConfig { 270 let categories = [ 271 Category(id: 1, name: "Drinks"), 272 Category(id: 2, name: "Bakery"), 273 Category(id: 3, name: "Lunch"), 274 Category(id: 4, name: "Snacks"), 275 Category(id: 5, name: "Specials"), 276 ] 277 let products = [ 278 ConfigProduct( 279 id: "coffee", 280 productId: "coffee", 281 productName: "House Coffee", 282 description: "Freshly brewed fair-trade blend", 283 price: Amount(currency: currency, value: 3, fraction: 80000000), 284 image: imageDataUri("coffee"), 285 categories: [1] 286 ), 287 ConfigProduct( 288 id: "tea", 289 productId: "tea", 290 productName: "Herbal Tea", 291 description: "Mint and lemon verbena infusion", 292 price: Amount(currency: currency, value: 3, fraction: 40000000), 293 image: imageDataUri("tea"), 294 categories: [1] 295 ), 296 ConfigProduct( 297 id: "juice", 298 productId: "juice", 299 productName: "Apple Juice", 300 description: "Pressed Swiss apples, 30 cl", 301 price: Amount(currency: currency, value: 4, fraction: 20000000), 302 image: imageDataUri("juice"), 303 categories: [1] 304 ), 305 ConfigProduct( 306 id: "espresso", 307 productId: "espresso", 308 productName: "Espresso", 309 description: "Single origin, short pull", 310 price: Amount(currency: currency, value: 3, fraction: 20000000), 311 image: imageDataUri("espresso"), 312 categories: [1] 313 ), 314 ConfigProduct( 315 id: "lemonade", 316 productId: "lemonade", 317 productName: "Lemonade", 318 description: "Sparkling lemon and ginger", 319 price: Amount(currency: currency, value: 4, fraction: 40000000), 320 image: imageDataUri("lemonade"), 321 categories: [1, 5] 322 ), 323 ConfigProduct( 324 id: "croissant", 325 productId: "croissant", 326 productName: "Butter Croissant", 327 description: "All-butter pastry baked this morning", 328 price: Amount(currency: currency, value: 2, fraction: 90000000), 329 image: imageDataUri("croissant"), 330 categories: [2] 331 ), 332 ConfigProduct( 333 id: "muffin", 334 productId: "muffin", 335 productName: "Blueberry Muffin", 336 description: "With lemon crumble topping", 337 price: Amount(currency: currency, value: 4, fraction: 60000000), 338 image: imageDataUri("muffin"), 339 categories: [2, 4] 340 ), 341 ConfigProduct( 342 id: "bagel", 343 productId: "bagel", 344 productName: "Sesame Bagel", 345 description: "Toasted with cream cheese", 346 price: Amount(currency: currency, value: 5, fraction: 20000000), 347 image: imageDataUri("bagel"), 348 categories: [2] 349 ), 350 ConfigProduct( 351 id: "sandwich", 352 productId: "sandwich", 353 productName: "Veggie Sandwich", 354 description: "Grilled vegetables, hummus, seeded roll", 355 price: Amount(currency: currency, value: 8, fraction: 50000000), 356 image: imageDataUri("sandwich"), 357 categories: [3] 358 ), 359 ConfigProduct( 360 id: "wrap", 361 productId: "wrap", 362 productName: "Chicken Wrap", 363 description: "Herbs, salad, yogurt dressing", 364 price: Amount(currency: currency, value: 8, fraction: 90000000), 365 image: imageDataUri("wrap"), 366 categories: [3] 367 ), 368 ConfigProduct( 369 id: "salad", 370 productId: "salad", 371 productName: "Garden Salad", 372 description: "Mixed greens with vinaigrette", 373 price: Amount(currency: currency, value: 7, fraction: 50000000), 374 image: imageDataUri("salad"), 375 categories: [3, 5] 376 ), 377 ConfigProduct( 378 id: "soup", 379 productId: "soup", 380 productName: "Pumpkin Soup", 381 description: "Seasonal special, served warm", 382 price: Amount(currency: currency, value: 7, fraction: 20000000), 383 image: imageDataUri("soup"), 384 categories: [3, 5], 385 totalStock: 0 386 ), 387 ConfigProduct( 388 id: "quiche", 389 productId: "quiche", 390 productName: "Quiche Lorraine", 391 description: "Classic egg and bacon tart", 392 price: Amount(currency: currency, value: 7, fraction: 20000000), 393 image: imageDataUri("quiche"), 394 categories: [3, 5] 395 ), 396 ConfigProduct( 397 id: "granola", 398 productId: "granola", 399 productName: "Granola Bowl", 400 description: "Oats, nuts, seasonal berries", 401 price: Amount(currency: currency, value: 5, fraction: 90000000), 402 image: imageDataUri("granola"), 403 categories: [4, 5] 404 ), 405 ConfigProduct( 406 id: "chips", 407 productId: "chips", 408 productName: "Veggie Chips", 409 description: "Root vegetable crisps, sea salt", 410 price: Amount(currency: currency, value: 3, fraction: 50000000), 411 image: imageDataUri("chips"), 412 categories: [4] 413 ), 414 ConfigProduct( 415 id: "fruit", 416 productId: "fruit", 417 productName: "Fresh Fruit Cup", 418 description: "Seasonal sliced fruit mix", 419 price: Amount(currency: currency, value: 4, fraction: 80000000), 420 image: imageDataUri("fruit"), 421 categories: [4, 5] 422 ), 423 ] 424 return PosConfig(categories: categories, products: products) 425 } 426 }