taler-ios

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

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 }