taler-ios

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

commit a70e87859e6f553966fbf640bee2fe35c2e2676b
parent 3e7378e4523b6f5550790828915bc971d9bb36da
Author: Bohdan Potuzhnyi <bohdan.potuzhnyi@gmail.com>
Date:   Wed,  8 Jul 2026 15:27:51 +0200

[pos] couple of updates on the screenshot flow + bug fixes

Diffstat:
MTalerPOS.xcodeproj/project.pbxproj | 8++++----
MTalerPOS/App/ContentView.swift | 30+++++++++++++++++++++++++-----
MTalerPOS/App/TalerPOSApp.swift | 3+++
MTalerPOS/Debug/ScreenshotController.swift | 279++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
MTalerPOS/History/HistoryManager.swift | 2++
MTalerPOS/Resources/Localizable.xcstrings | 36+++++++++++++++++++++++++++++++++---
MTalerPOS/Views/ConfigView.swift | 145++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
MTalerPOS/Views/OrderView.swift | 7++++++-
MTalerPOS/scripts/capture-screenshots.sh | 160+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
MTalerPOSUITests/ScreenshotTests.swift | 70+++++++++++++++++++++++++++++++++++++++++++++++++---------------------
10 files changed, 639 insertions(+), 101 deletions(-)

diff --git a/TalerPOS.xcodeproj/project.pbxproj b/TalerPOS.xcodeproj/project.pbxproj @@ -623,7 +623,7 @@ CODE_SIGN_ENTITLEMENTS = TalerPOS/Resources/TalerPOS.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 6; DEVELOPMENT_ASSET_PATHS = "\"TalerPOS/Resources/Preview Content\""; DEVELOPMENT_TEAM = GUDDQ9428Y; ENABLE_PREVIEWS = YES; @@ -641,7 +641,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.1.1; + MARKETING_VERSION = 0.1.2; PRODUCT_BUNDLE_IDENTIFIER = "com.taler-systems.talersale-2"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -663,7 +663,7 @@ CODE_SIGN_ENTITLEMENTS = TalerPOS/TalerPOSRelease.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 6; DEVELOPMENT_ASSET_PATHS = "\"TalerPOS/Resources/Preview Content\""; DEVELOPMENT_TEAM = GUDDQ9428Y; ENABLE_PREVIEWS = YES; @@ -681,7 +681,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.1.1; + MARKETING_VERSION = 0.1.2; PRODUCT_BUNDLE_IDENTIFIER = "com.taler-systems.talersale-2"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/TalerPOS/App/ContentView.swift b/TalerPOS/App/ContentView.swift @@ -265,13 +265,33 @@ struct ContentView: View { } .onAppear { if let scenario = ScreenshotController.activeScenario { - let dest = scenario.destination - if dest.isMainDestination { - currentRoot = dest - } else { + switch scenario { + case .refund: + currentRoot = .history + DispatchQueue.main.async { + if let item = refundManager.toBeRefunded { + navigationPath.append(PosDestination.refund(item)) + } + } + case .refundQr: + currentRoot = .history + DispatchQueue.main.async { + navigationPath.append(PosDestination.refundUri) + } + case .navigation: currentRoot = .order DispatchQueue.main.async { - navigationPath.append(dest) + drawerOpen = true + } + default: + let dest = scenario.destination + if dest.isMainDestination { + currentRoot = dest + } else { + currentRoot = .order + DispatchQueue.main.async { + navigationPath.append(dest) + } } } } else if let restored = PosDestination.from(savedKey: savedRoot), restored.isMainDestination { diff --git a/TalerPOS/App/TalerPOSApp.swift b/TalerPOS/App/TalerPOSApp.swift @@ -113,10 +113,13 @@ struct TalerPOSApp: App { .environmentObject(refundManager) .onAppear { if let scenario = ScreenshotController.activeScenario { + ScreenshotController.applyLocaleIfNeeded() ScreenshotController.applyFixture( configManager: configManager, orderManager: orderManager, paymentManager: paymentManager, + historyManager: historyManager, + refundManager: refundManager, scenario: scenario ) } else { diff --git a/TalerPOS/Debug/ScreenshotController.swift b/TalerPOS/Debug/ScreenshotController.swift @@ -17,17 +17,40 @@ import Foundation enum ScreenshotScenario: String, CaseIterable { + case login = "login" + case mfaSelect = "mfa-select" + case mfaCode = "mfa-code" case amountEntry = "amount-entry" case order = "order" + case orderCustom = "order-custom" + case orderCustomAdded = "order-custom-added" case payment = "payment" case paymentSuccess = "payment-success" + case history = "history" + case refund = "refund" + case refundQr = "refund-qr" + case navigation = "navigation" + case settings = "settings" var destination: PosDestination { switch self { + case .login, .mfaSelect, .mfaCode: return .config case .amountEntry: return .amountEntry - case .order: return .order + case .order, .orderCustom, .orderCustomAdded: return .order case .payment: return .processPayment case .paymentSuccess: return .paymentSuccess + case .history: return .history + case .refund: return .history + case .refundQr: return .history + case .navigation: return .order + case .settings: return .settings + } + } + + var isConfigScreen: Bool { + switch self { + case .login, .mfaSelect, .mfaCode: return true + default: return false } } } @@ -35,19 +58,42 @@ enum ScreenshotScenario: String, CaseIterable { @MainActor enum ScreenshotController { private static let launchArgument = "SCREENSHOT_SCENARIO" + private static let localeArgument = "SCREENSHOT_LOCALE" private static let currency = "CHF" static var activeScenario: ScreenshotScenario? { guard let value = ProcessInfo.processInfo.environment[launchArgument] - ?? parseLaunchArgument() else { return nil } + ?? parseLaunchArgument(launchArgument) else { return nil } return ScreenshotScenario(rawValue: value) } static var isActive: Bool { activeScenario != nil } + static var showCustomDialog: Bool { activeScenario == .orderCustom } + static var showDrawer: Bool { activeScenario == .navigation } + static var showMfaSelect: Bool { activeScenario == .mfaSelect } + static var showMfaCode: Bool { activeScenario == .mfaCode } - private static func parseLaunchArgument() -> String? { + static var mfaMockChallenges: [MfaChallenge] { + [ + MfaChallenge(challengeId: "ch-sms-1", tanChannel: .sms, tanInfo: "+41 79 *** ** 42"), + MfaChallenge(challengeId: "ch-email-1", tanChannel: .email, tanInfo: "m***@example.com"), + ] + } + + static var mfaMockTanChallenge: MfaChallenge { + MfaChallenge(challengeId: "ch-sms-1", tanChannel: .sms, tanInfo: "+41 79 *** ** 42") + } + + static func applyLocaleIfNeeded() { + guard let tag = ProcessInfo.processInfo.environment[localeArgument] + ?? parseLaunchArgument(localeArgument), + !tag.isEmpty else { return } + LanguageManager.shared.setLanguage(tag) + } + + private static func parseLaunchArgument(_ key: String) -> String? { let args = ProcessInfo.processInfo.arguments - guard let idx = args.firstIndex(of: "-\(launchArgument)"), + guard let idx = args.firstIndex(of: "-\(key)"), idx + 1 < args.count else { return nil } return args[idx + 1] } @@ -56,8 +102,15 @@ enum ScreenshotController { configManager: ConfigManager, orderManager: OrderManager, paymentManager: PaymentManager, + historyManager: HistoryManager, + refundManager: RefundManager, scenario: ScreenshotScenario ) { + if scenario.isConfigScreen { + configManager.merchantUrl = "backend.demo.taler.net" + return + } + let fixture = buildFixture() configManager.merchantUrl = "https://merchant.example.invalid/instances/demo" configManager.accessToken = "screenshot-fixture" @@ -73,11 +126,35 @@ enum ScreenshotController { currencySpec: nil ) - seedOrder(orderManager: orderManager, productIds: ["coffee", "croissant", "sandwich", "coffee"]) + switch scenario { + case .order, .orderCustom, .orderCustomAdded, .navigation: + seedOrder(orderManager: orderManager, productIds: [ + "lemonade", "lemonade", "lemonade", "lemonade", "lemonade", + "wrap", "wrap", + "chips", + "salad", "salad", + ]) + default: + seedOrder(orderManager: orderManager, productIds: ["coffee", "croissant", "sandwich", "coffee"]) + } switch scenario { - case .amountEntry, .order: + case .login, .mfaSelect, .mfaCode: break + case .amountEntry, .order, .navigation, .settings: + break + case .orderCustom: + break + case .orderCustomAdded: + if let liveOrder = orderManager.currentLiveOrder { + let customProduct = ConfigProduct( + description: S.orderCustomProductDefault, + price: Amount(currency: currency, value: 2, fraction: 73000000), + categories: [Int.min], + quantity: 1 + ) + orderManager.addProduct(orderId: liveOrder.id, product: customProduct) + } case .payment: if let liveOrder = orderManager.currentLiveOrder { paymentManager.payment = Payment( @@ -100,6 +177,19 @@ enum ScreenshotController { paid: true ) } + case .history: + seedHistory(historyManager: historyManager) + case .refund: + let item = buildRefundItem() + refundManager.startRefund(item: item) + case .refundQr: + let item = buildRefundItem() + refundManager.refundResult = .success( + refundUri: "taler://refund/example.invalid/2026-ORD-1047/REFUND-ABC123", + item: item, + amount: Amount(currency: currency, value: 18, fraction: 30000000), + reason: S.orderCustomProductDefault + ) } } @@ -112,6 +202,64 @@ enum ScreenshotController { } } + private static func seedHistory(historyManager: HistoryManager) { + let now = UInt64(Date().timeIntervalSince1970) + historyManager.items = [ + OrderHistoryEntry( + orderId: "2026-ORD-1048", + rowId: 48, + timestamp: Timestamp(seconds: now - 60), + amount: Amount(currency: currency, value: 12, fraction: 40000000), + summary: "Chicken Wrap + Lemonade", + paid: false, + refundable: false + ), + OrderHistoryEntry( + orderId: "2026-ORD-1047", + rowId: 47, + timestamp: Timestamp(seconds: now - 300), + amount: Amount(currency: currency, value: 18, fraction: 30000000), + summary: "Veggie Sandwich + House Coffee", + paid: true, + refundable: true + ), + OrderHistoryEntry( + orderId: "2026-ORD-1046", + rowId: 46, + timestamp: Timestamp(seconds: now - 900), + amount: Amount(currency: currency, value: 6, fraction: 70000000), + summary: "Butter Croissant + Herbal Tea", + paid: true, + refundable: true, + pendingRefundAmount: Amount(currency: currency, value: 6, fraction: 70000000), + refundPending: true + ), + OrderHistoryEntry( + orderId: "2026-ORD-1045", + rowId: 45, + timestamp: Timestamp(seconds: now - 1800), + amount: Amount(currency: currency, value: 3, fraction: 80000000), + summary: "House Coffee", + paid: true, + refundable: false, + refunded: true, + refundAmount: Amount(currency: currency, value: 3, fraction: 80000000) + ), + ] + } + + private static func buildRefundItem() -> OrderHistoryEntry { + OrderHistoryEntry( + orderId: "2026-ORD-1047", + rowId: 47, + timestamp: Timestamp(seconds: UInt64(Date().timeIntervalSince1970)), + amount: Amount(currency: currency, value: 18, fraction: 30000000), + summary: "Veggie Sandwich + House Coffee", + paid: true, + refundable: true + ) + } + private static func imageDataUri(_ name: String) -> String? { guard let url = Bundle.main.url(forResource: name, withExtension: "png"), let data = try? Data(contentsOf: url) else { return nil } @@ -123,13 +271,15 @@ enum ScreenshotController { Category(id: 1, name: "Drinks"), Category(id: 2, name: "Bakery"), Category(id: 3, name: "Lunch"), + Category(id: 4, name: "Snacks"), + Category(id: 5, name: "Specials"), ] let products = [ ConfigProduct( id: "coffee", productId: "coffee", productName: "House Coffee", - description: "House Coffee", + description: "Freshly brewed fair-trade blend", price: Amount(currency: currency, value: 3, fraction: 80000000), image: imageDataUri("coffee"), categories: [1] @@ -138,29 +288,138 @@ enum ScreenshotController { id: "tea", productId: "tea", productName: "Herbal Tea", - description: "Herbal Tea", + description: "Mint and lemon verbena infusion", price: Amount(currency: currency, value: 3, fraction: 40000000), image: imageDataUri("tea"), categories: [1] ), ConfigProduct( + id: "juice", + productId: "juice", + productName: "Apple Juice", + description: "Pressed Swiss apples, 30 cl", + price: Amount(currency: currency, value: 4, fraction: 20000000), + image: imageDataUri("juice"), + categories: [1] + ), + ConfigProduct( + id: "espresso", + productId: "espresso", + productName: "Espresso", + description: "Single origin, short pull", + price: Amount(currency: currency, value: 3, fraction: 20000000), + image: imageDataUri("espresso"), + categories: [1] + ), + ConfigProduct( + id: "lemonade", + productId: "lemonade", + productName: "Lemonade", + description: "Sparkling lemon and ginger", + price: Amount(currency: currency, value: 4, fraction: 40000000), + image: imageDataUri("lemonade"), + categories: [1, 5] + ), + ConfigProduct( id: "croissant", productId: "croissant", productName: "Butter Croissant", - description: "Butter Croissant", + description: "All-butter pastry baked this morning", price: Amount(currency: currency, value: 2, fraction: 90000000), image: imageDataUri("croissant"), categories: [2] ), ConfigProduct( + id: "muffin", + productId: "muffin", + productName: "Blueberry Muffin", + description: "With lemon crumble topping", + price: Amount(currency: currency, value: 4, fraction: 60000000), + image: imageDataUri("muffin"), + categories: [2, 4] + ), + ConfigProduct( + id: "bagel", + productId: "bagel", + productName: "Sesame Bagel", + description: "Toasted with cream cheese", + price: Amount(currency: currency, value: 5, fraction: 20000000), + image: imageDataUri("bagel"), + categories: [2] + ), + ConfigProduct( id: "sandwich", productId: "sandwich", productName: "Veggie Sandwich", - description: "Veggie Sandwich", + description: "Grilled vegetables, hummus, seeded roll", price: Amount(currency: currency, value: 8, fraction: 50000000), image: imageDataUri("sandwich"), categories: [3] ), + ConfigProduct( + id: "wrap", + productId: "wrap", + productName: "Chicken Wrap", + description: "Herbs, salad, yogurt dressing", + price: Amount(currency: currency, value: 8, fraction: 90000000), + image: imageDataUri("wrap"), + categories: [3] + ), + ConfigProduct( + id: "salad", + productId: "salad", + productName: "Garden Salad", + description: "Mixed greens with vinaigrette", + price: Amount(currency: currency, value: 7, fraction: 50000000), + image: imageDataUri("salad"), + categories: [3, 5] + ), + ConfigProduct( + id: "soup", + productId: "soup", + productName: "Pumpkin Soup", + description: "Seasonal special, served warm", + price: Amount(currency: currency, value: 7, fraction: 20000000), + image: imageDataUri("soup"), + categories: [3, 5], + totalStock: 0 + ), + ConfigProduct( + id: "quiche", + productId: "quiche", + productName: "Quiche Lorraine", + description: "Classic egg and bacon tart", + price: Amount(currency: currency, value: 7, fraction: 20000000), + image: imageDataUri("quiche"), + categories: [3, 5] + ), + ConfigProduct( + id: "granola", + productId: "granola", + productName: "Granola Bowl", + description: "Oats, nuts, seasonal berries", + price: Amount(currency: currency, value: 5, fraction: 90000000), + image: imageDataUri("granola"), + categories: [4, 5] + ), + ConfigProduct( + id: "chips", + productId: "chips", + productName: "Veggie Chips", + description: "Root vegetable crisps, sea salt", + price: Amount(currency: currency, value: 3, fraction: 50000000), + image: imageDataUri("chips"), + categories: [4] + ), + ConfigProduct( + id: "fruit", + productId: "fruit", + productName: "Fresh Fruit Cup", + description: "Seasonal sliced fruit mix", + price: Amount(currency: currency, value: 4, fraction: 80000000), + image: imageDataUri("fruit"), + categories: [4, 5] + ), ] return PosConfig(categories: categories, products: products) } diff --git a/TalerPOS/History/HistoryManager.swift b/TalerPOS/History/HistoryManager.swift @@ -42,6 +42,7 @@ class HistoryManager: ObservableObject { } func fetchHistory() { + if ScreenshotController.isActive { return } fetchHistoryPage(reset: true) } @@ -92,6 +93,7 @@ class HistoryManager: ObservableObject { } private func fetchHistoryPage(reset: Bool) { + if ScreenshotController.isActive { return } guard let merchantConfig = configManager.merchantConfig else { logger.error("fetchHistoryPage: no merchantConfig, bailing") isLoading = false diff --git a/TalerPOS/Resources/Localizable.xcstrings b/TalerPOS/Resources/Localizable.xcstrings @@ -4,6 +4,15 @@ "" : { }, + " Check out the setup tutorials at" : { + + }, + " If you are reviewing this app, you most likely already have access to a controlled test user. To prefill the merchant portal URL, press the button below." : { + + }, + " You can try the app with the public demo instance. Note that this instance is shared — anyone can modify its data at any time. Use it only to explore the app and its features." : { + + }, "-" : { }, @@ -40,6 +49,15 @@ "%lld" : { }, + "1." : { + + }, + "2." : { + + }, + "3." : { + + }, "amount_entry_backspace" : { "extractionState" : "stale", "localizations" : { @@ -750,9 +768,6 @@ } } }, - "backend.example.com" : { - - }, "common_error" : { "extractionState" : "stale", "localizations" : { @@ -3722,6 +3737,12 @@ } } }, + "Fill demo credentials" : { + + }, + "Fill review URL" : { + + }, "force_delete_dialog_confirm" : { "extractionState" : "stale", "localizations" : { @@ -6355,6 +6376,9 @@ } } }, + "my.taler-ops.ch" : { + + }, "never_expires" : { "extractionState" : "stale", "localizations" : { @@ -6426,6 +6450,9 @@ } } }, + "No instance?" : { + + }, "no_deadline_set" : { "extractionState" : "stale", "localizations" : { @@ -11782,6 +11809,9 @@ } } } + }, + "tutorials.taler.net" : { + } }, "version" : "1.0" diff --git a/TalerPOS/Views/ConfigView.swift b/TalerPOS/Views/ConfigView.swift @@ -39,6 +39,7 @@ struct ConfigView: View { @State private var saveToken: Bool = true @State private var isSubmitting: Bool = false @State private var showPassword: Bool = false + @State private var showInstanceInfo: Bool = false @State private var errorMessage: String? = nil @FocusState private var focusedField: ConfigField? @@ -185,22 +186,22 @@ struct ConfigView: View { Text(S.configMerchantUrl) .font(.caption) .foregroundColor(.posOnSurfaceVariant) - HStack { + HStack(spacing: 8) { Text("https://") .foregroundColor(.posOnSurfaceVariant) - TextField("backend.example.com", text: $merchantUrlText) + TextField("my.taler-ops.ch", text: $merchantUrlText) .keyboardType(.URL) .autocapitalization(.none) .disableAutocorrection(true) .focused($focusedField, equals: .merchantUrl) .submitLabel(.next) .onSubmit { focusedField = .username } + .padding(10) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.posOutline, lineWidth: 1) + ) } - .padding(10) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(Color.posOutline, lineWidth: 1) - ) } VStack(alignment: .leading, spacing: 4) { @@ -278,11 +279,113 @@ struct ConfigView: View { .foregroundColor(.posError) .padding(.top, 4) } + + instanceInfoBox } .padding(20) } } + // MARK: - Instance info box + + private var instanceInfoBox: some View { + VStack(alignment: .leading, spacing: 0) { + Button { + withAnimation(.easeInOut(duration: 0.2)) { showInstanceInfo.toggle() } + } label: { + HStack { + Text("No instance?") + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.posOnSurface) + Spacer() + Image(systemName: showInstanceInfo ? "chevron.up" : "chevron.down") + .font(.caption) + .foregroundColor(.posOnSurfaceVariant) + } + .padding(12) + } + .buttonStyle(.plain) + + if showInstanceInfo { + VStack(alignment: .leading, spacing: 16) { + Divider() + + VStack(alignment: .leading, spacing: 4) { + (Text("1.") + .fontWeight(.semibold) + .foregroundColor(.posOnSurface) + + Text(" Check out the setup tutorials at") + .foregroundColor(.posOnSurfaceVariant)) + .font(.subheadline) + + Link("tutorials.taler.net", destination: URL(string: "https://tutorials.taler.net/merchant/merchant-backoffice/obtain-instance/index")!) + .font(.subheadline) + .fontWeight(.semibold) + } + + VStack(alignment: .leading, spacing: 8) { + Text("2.") + .fontWeight(.semibold) + .foregroundColor(.posOnSurface) + + Text(" You can try the app with the public demo instance. Note that this instance is shared — anyone can modify its data at any time. Use it only to explore the app and its features.") + .foregroundColor(.posOnSurfaceVariant) + + Button { + merchantUrlText = "backend.demo.taler.net" + usernameText = "sandbox" + tokenText = "sandbox" + } label: { + Text("Fill demo credentials") + .font(.subheadline) + .fontWeight(.medium) + .padding(.vertical, 8) + .padding(.horizontal, 14) + .background(Color.posSecondaryContainer) + .foregroundColor(.posOnSecondaryContainer) + .clipShape(RoundedRectangle(cornerRadius: 20)) + } + .buttonStyle(.plain) + } + .font(.subheadline) + + VStack(alignment: .leading, spacing: 8) { + Text("3.") + .fontWeight(.semibold) + .foregroundColor(.posOnSurface) + + Text(" If you are reviewing this app, you most likely already have access to a controlled test user. To prefill the merchant portal URL, press the button below.") + .foregroundColor(.posOnSurfaceVariant) + + Button { + merchantUrlText = "backend.demo.taler.net" + usernameText = "" + tokenText = "" + } label: { + Text("Fill review URL") + .font(.subheadline) + .fontWeight(.medium) + .padding(.vertical, 8) + .padding(.horizontal, 14) + .background(Color.posSecondaryContainer) + .foregroundColor(.posOnSecondaryContainer) + .clipShape(RoundedRectangle(cornerRadius: 20)) + } + .buttonStyle(.plain) + } + .font(.subheadline) + } + .padding(.horizontal, 12) + .padding(.bottom, 12) + } + } + .background(Color.posSurface) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.posOutline, lineWidth: 1) + ) + } + // MARK: - Challenge selection private func challengeSelectionContent(challenges: [MfaChallenge]) -> some View { @@ -341,9 +444,35 @@ struct ConfigView: View { merchantUrlText = configManager.merchantUrl saveToken = configManager.savePassword - if !merchantUrlText.isEmpty { + if merchantUrlText.isEmpty { + merchantUrlText = "my.taler-ops.ch" + } else { sanitizeMerchantUrlAndUpdateFields() } + + if ScreenshotController.isActive { + if ScreenshotController.activeScenario == .login { + usernameText = "pos" + tokenText = "secret-password" + } + if ScreenshotController.showMfaSelect { + usernameText = "pos" + tokenText = "secret-password" + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + challengeSelectionData = ChallengeSelectionData( + challenges: ScreenshotController.mfaMockChallenges + ) + } + } + if ScreenshotController.showMfaCode { + usernameText = "pos" + tokenText = "secret-password" + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + tanDigits = ["5", "8", "2", "7", "", "", "", ""] + tanChallenge = ScreenshotController.mfaMockTanChallenge + } + } + } } private func submitManualConfig() { diff --git a/TalerPOS/Views/OrderView.swift b/TalerPOS/Views/OrderView.swift @@ -37,7 +37,12 @@ struct OrderView: View { threeColumnLayout } .navigationBarHidden(true) - .onAppear { startInventoryRefresh() } + .onAppear { + startInventoryRefresh() + if ScreenshotController.showCustomDialog { + showCustomProduct = true + } + } .onDisappear { stopInventoryRefresh() } .sheet(isPresented: $showCustomProduct) { CustomProductDialog(currency: orderManager.currentLiveOrder?.order.currency ?? configManager.currency ?? "", currencySpec: configManager.currencySpec) { product in diff --git a/TalerPOS/scripts/capture-screenshots.sh b/TalerPOS/scripts/capture-screenshots.sh @@ -4,32 +4,63 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" -BASE_DIR="${1:-$REPO_ROOT/TalerPOS/build/screenshots}" -# Accept simulators as remaining args, or auto-detect -shift || true -if [[ $# -gt 0 ]]; then - SIMULATORS=("$@") -else - echo "Auto-detecting available simulators..." - # Pick the latest-OS iPhone and iPad simulators - SIMULATORS=() - IPHONE=$(xcodebuild -project "$REPO_ROOT/TalerPOS.xcodeproj" -scheme TalerPOS -showdestinations 2>/dev/null \ - | grep 'platform:iOS Simulator' | grep 'name:iPhone' \ - | sed 's/.*name:\([^,}]*\).*/\1/' | sed 's/ *$//' | sort -u | tail -1) - IPAD=$(xcodebuild -project "$REPO_ROOT/TalerPOS.xcodeproj" -scheme TalerPOS -showdestinations 2>/dev/null \ - | grep 'platform:iOS Simulator' | grep 'name:iPad' \ - | sed 's/.*name:\([^,}]*\).*/\1/' | sed 's/ *$//' | sort -u | tail -1) - [[ -n "$IPHONE" ]] && SIMULATORS+=("$IPHONE") - [[ -n "$IPAD" ]] && SIMULATORS+=("$IPAD") - - if [[ ${#SIMULATORS[@]} -eq 0 ]]; then - echo "No simulators found. Pass device names manually." >&2 - exit 1 - fi - echo " Found: ${SIMULATORS[*]}" - echo "" +# Defaults +BASE_DIR="$REPO_ROOT/TalerPOS/build/screenshots" +ONLY_SCENARIOS="" + +# Parse flags +while [[ $# -gt 0 ]]; do + case "$1" in + -s|--scenario) + ONLY_SCENARIOS="$2" + shift 2 + ;; + -o|--output) + BASE_DIR="$2" + shift 2 + ;; + -h|--help) + echo "Usage: $(basename "$0") [options]" + echo "" + echo "Options:" + echo " -s, --scenario <name> Run only the specified scenario(s), comma-separated" + echo " e.g. -s history or -s login,mfa-select,mfa-code" + echo " -o, --output <dir> Output directory (default: TalerPOS/build/screenshots)" + echo " -h, --help Show this help" + echo "" + echo "Scenarios: login, mfa-select, mfa-code, amount-entry, order, order-custom," + echo " order-custom-added, payment, payment-success, history, refund," + echo " refund-qr, navigation" + exit 0 + ;; + *) + echo "Unknown option: $1 (use -h for help)" >&2 + exit 1 + ;; + esac +done + +# Auto-detect simulators +echo "Auto-detecting available simulators..." +SIMULATORS=() +IPHONE=$(xcodebuild -project "$REPO_ROOT/TalerPOS.xcodeproj" -scheme TalerPOS -showdestinations 2>/dev/null \ + | grep 'platform:iOS Simulator' | grep 'name:iPhone' \ + | sed 's/.*name:\([^,}]*\).*/\1/' | sed 's/ *$//' | sort -u | tail -1) +IPAD=$(xcodebuild -project "$REPO_ROOT/TalerPOS.xcodeproj" -scheme TalerPOS -showdestinations 2>/dev/null \ + | grep 'platform:iOS Simulator' | grep 'name:iPad' | grep '11-inch' \ + | sed 's/.*name:\([^,}]*\).*/\1/' | sed 's/ *$//' | sort -u | tail -1) +[[ -n "$IPHONE" ]] && SIMULATORS+=("$IPHONE") +[[ -n "$IPAD" ]] && SIMULATORS+=("$IPAD") + +if [[ ${#SIMULATORS[@]} -eq 0 ]]; then + echo "No simulators found." >&2 + exit 1 fi +echo " Found: ${SIMULATORS[*]}" +echo "" + +LOCALES=("en" "de" "fr") # Read version from the Xcode project VERSION=$(grep 'MARKETING_VERSION' "$REPO_ROOT/TalerPOS.xcodeproj/project.pbxproj" \ @@ -55,7 +86,14 @@ $UI_TEST_REF " "$SCHEME_FILE" fi -echo "=== Capturing screenshots for version $VERSION ===" +# Build test target to pick the right test method +if [[ -n "$ONLY_SCENARIOS" ]]; then + TEST_TARGET="-only-testing:TalerPOSUITests/ScreenshotTests/testSelectedScenarios" + echo "=== Capturing scenario(s): $ONLY_SCENARIOS ===" +else + TEST_TARGET="-only-testing:TalerPOSUITests/ScreenshotTests/testAllScreenshots" + echo "=== Capturing all screenshots for version $VERSION ===" +fi echo "" for SIMULATOR in "${SIMULATORS[@]}"; do @@ -63,32 +101,50 @@ for SIMULATOR in "${SIMULATORS[@]}"; do # Sanitize device name for folder (spaces -> underscores, lowercase) DEVICE_FOLDER=$(echo "$SIMULATOR" | tr ' ' '_' | tr '[:upper:]' '[:lower:]') - DEVICE_DIR="$BASE_DIR/$VERSION/$DEVICE_FOLDER" - mkdir -p "$DEVICE_DIR" - RESULT_BUNDLE="$DEVICE_DIR/ScreenshotTests.xcresult" - rm -rf "$RESULT_BUNDLE" + # Boot simulator and override status bar to 9:41 + SIM_UDID=$(xcrun simctl list devices available | grep "$SIMULATOR" | head -1 | sed 's/.*(\([A-F0-9-]*\)).*/\1/') + if [[ -n "$SIM_UDID" ]]; then + xcrun simctl boot "$SIM_UDID" 2>/dev/null || true + sleep 3 + xcrun simctl status_bar "$SIM_UDID" override \ + --time "9:41" \ + --batteryState charged \ + --batteryLevel 100 \ + --cellularBars 4 \ + --wifiBars 3 + echo " Status bar set to 9:41 on $SIM_UDID" + fi + + for LOCALE in "${LOCALES[@]}"; do + echo " === Locale: $LOCALE ===" + + DEVICE_DIR="$BASE_DIR/$VERSION/$DEVICE_FOLDER/$LOCALE" + mkdir -p "$DEVICE_DIR" + + RESULT_BUNDLE="$DEVICE_DIR/ScreenshotTests.xcresult" + rm -rf "$RESULT_BUNDLE" - TMPDIR_EXPORT=$(mktemp -d) + TMPDIR_EXPORT=$(mktemp -d) - xcodebuild test \ - -project "$REPO_ROOT/TalerPOS.xcodeproj" \ - -scheme TalerPOS \ - -destination "platform=iOS Simulator,name=$SIMULATOR" \ - -only-testing:TalerPOSUITests/ScreenshotTests \ - -resultBundlePath "$RESULT_BUNDLE" \ - | grep -E '(Test Case|Test Suite|error:|BUILD|Failing)' || true + SCREENSHOT_LOCALE="$LOCALE" SCREENSHOT_SCENARIOS="$ONLY_SCENARIOS" xcodebuild test \ + -project "$REPO_ROOT/TalerPOS.xcodeproj" \ + -scheme TalerPOS \ + -destination "platform=iOS Simulator,name=$SIMULATOR" \ + $TEST_TARGET \ + -resultBundlePath "$RESULT_BUNDLE" \ + | grep -E '(Test Case|Test Suite|error:|BUILD|Failing)' || true - echo "" + echo "" - # Export attachments + manifest - xcrun xcresulttool export attachments \ - --path "$RESULT_BUNDLE" \ - --output-path "$TMPDIR_EXPORT" + # Export attachments + manifest + xcrun xcresulttool export attachments \ + --path "$RESULT_BUNDLE" \ + --output-path "$TMPDIR_EXPORT" - # Use manifest.json to rename files by scenario name - if [[ -f "$TMPDIR_EXPORT/manifest.json" ]]; then - python3 -c " + # Use manifest.json to rename files by scenario name + if [[ -f "$TMPDIR_EXPORT/manifest.json" ]]; then + python3 -c " import json, shutil, os manifest = json.load(open('$TMPDIR_EXPORT/manifest.json')) for entry in manifest: @@ -102,12 +158,18 @@ for entry in manifest: shutil.move(src, dst) print(f' {scenario}.png') " - fi + fi + + rm -rf "$TMPDIR_EXPORT" + rm -rf "$RESULT_BUNDLE" - rm -rf "$TMPDIR_EXPORT" - rm -rf "$RESULT_BUNDLE" + echo "" + done - echo "" + # Clear status bar override + if [[ -n "$SIM_UDID" ]]; then + xcrun simctl status_bar "$SIM_UDID" clear + fi done echo "=== Done ===" diff --git a/TalerPOSUITests/ScreenshotTests.swift b/TalerPOSUITests/ScreenshotTests.swift @@ -18,33 +18,61 @@ import XCTest final class ScreenshotTests: XCTestCase { - private func launchAndCapture(scenario: String) { - let app = XCUIApplication() - app.launchArguments += ["-SCREENSHOT_SCENARIO", scenario] - app.launch() - - sleep(2) - - let screenshot = app.windows.firstMatch.screenshot() - let attachment = XCTAttachment(screenshot: screenshot) - attachment.name = scenario - attachment.lifetime = .keepAlways - add(attachment) - } + private static let scenarios = [ + "login", + "mfa-select", + "mfa-code", + "amount-entry", + "order", + "order-custom", + "order-custom-added", + "payment", + "payment-success", + "history", + "refund", + "refund-qr", + "navigation", + "settings", + ] - func testScreenshotAmountEntry() { - launchAndCapture(scenario: "amount-entry") + private var screenshotLocale: String { + ProcessInfo.processInfo.environment["SCREENSHOT_LOCALE"] ?? "en" } - func testScreenshotOrder() { - launchAndCapture(scenario: "order") + func testAllScreenshots() { + runScenarios(Self.scenarios) } - func testScreenshotPayment() { - launchAndCapture(scenario: "payment") + func testSelectedScenarios() { + let env = ProcessInfo.processInfo.environment["SCREENSHOT_SCENARIOS"] ?? "" + let selected = env.split(separator: ",").map(String.init) + guard !selected.isEmpty else { + XCTFail("SCREENSHOT_SCENARIOS env var is empty") + return + } + runScenarios(selected) } - func testScreenshotPaymentSuccess() { - launchAndCapture(scenario: "payment-success") + private func runScenarios(_ scenarios: [String]) { + for scenario in scenarios { + let app = XCUIApplication() + app.launchArguments = ["-SCREENSHOT_SCENARIO", scenario, + "-SCREENSHOT_LOCALE", screenshotLocale] + app.launch() + + if UIDevice.current.userInterfaceIdiom == .pad { + XCUIDevice.shared.orientation = .landscapeLeft + } + + sleep(1) + + let screenshot = XCUIScreen.main.screenshot() + let attachment = XCTAttachment(screenshot: screenshot) + attachment.name = scenario + attachment.lifetime = .keepAlways + add(attachment) + + app.terminate() + } } }