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:
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()
+ }
}
}