commit ce61eb24235f6e564c38d8c2fdcdb0a329710003 parent 5a266b018e3e314c3dc4d5d67086c673780bce68 Author: Bohdan Potuzhnyi <bohdan.potuzhnyi@gmail.com> Date: Mon, 29 Jun 2026 11:38:21 +0200 [pos] some work on automatic screenshots + extend the qr-config reading rules for seb version of url Diffstat:
20 files changed, 1382 insertions(+), 139 deletions(-)
diff --git a/merchant-terminal/scripts/capture-screenshots.sh b/merchant-terminal/scripts/capture-screenshots.sh @@ -10,12 +10,15 @@ shift || true if [[ $# -gt 0 ]]; then SCENARIOS=("$@") else - SCENARIOS=("amount-entry" "order" "payment" "payment-success") + SCENARIOS=("login" "mfa-select" "mfa-code" "amount-entry" "order" "order-custom" "order-custom-added" "payment" "payment-success" "history" "refund" "refund-qr" "navigation") fi APP_ID="net.taler.merchantpos" -ACTIVITY="$APP_ID/.MainActivity" -EXTRA_KEY="taler_pos_screenshot_scenario" +ACTIVITY="$APP_ID/.debug.ScreenshotActivity" +EXTRA_KEY="screenshot_scenario" +LOGCAT_TAG="SCREENSHOT_READY" +WAIT_TIMEOUT=30 +BOOTED_EMULATOR="" mkdir -p "$OUT_DIR" @@ -24,19 +27,116 @@ if ! command -v adb >/dev/null 2>&1; then exit 1 fi -if ! adb get-state >/dev/null 2>&1; then - echo "No Android device or emulator detected" >&2 +find_emulator_cmd() { + if command -v emulator >/dev/null 2>&1; then + echo "emulator" + elif [[ -x "${ANDROID_HOME:-}/emulator/emulator" ]]; then + echo "${ANDROID_HOME}/emulator/emulator" + elif [[ -x "${ANDROID_SDK_ROOT:-}/emulator/emulator" ]]; then + echo "${ANDROID_SDK_ROOT}/emulator/emulator" + else + return 1 + fi +} + +shutdown_emulator() { + if [[ -n "$BOOTED_EMULATOR" ]]; then + echo "Shutting down emulator ($BOOTED_EMULATOR)..." + adb -s "$BOOTED_EMULATOR" emu kill 2>/dev/null || true + BOOTED_EMULATOR="" + fi +} + +trap shutdown_emulator EXIT + +CONNECTED_DEVICES=() +while IFS= read -r serial; do + CONNECTED_DEVICES+=("$serial") +done < <(adb devices | awk 'NR > 1 && $2 == "device" { print $1 }') + +if [[ ${#CONNECTED_DEVICES[@]} -eq 0 ]]; then + EMU_CMD="$(find_emulator_cmd)" || { + echo "No device connected and emulator command not found. Set ANDROID_HOME or add emulator to PATH." >&2 + exit 1 + } + + AVDS=() + while IFS= read -r avd; do + [[ -n "$avd" ]] && AVDS+=("$avd") + done < <("$EMU_CMD" -list-avds 2>/dev/null) + + if [[ ${#AVDS[@]} -eq 0 ]]; then + echo "No device connected and no AVDs found." >&2 + exit 1 + fi + + AVD="${AVDS[0]}" + echo "No device connected. Booting emulator: $AVD" + "$EMU_CMD" -avd "$AVD" -no-audio -no-boot-anim &>/dev/null & + + echo "Waiting for emulator to boot..." + adb wait-for-device + local_elapsed=0 + while [[ "$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" != "1" ]]; do + if [[ $local_elapsed -ge 120 ]]; then + echo "Emulator boot timed out after 120s" >&2 + exit 1 + fi + sleep 2 + local_elapsed=$((local_elapsed + 2)) + done + sleep 1 + echo "Emulator booted." + + while IFS= read -r serial; do + CONNECTED_DEVICES+=("$serial") + done < <(adb devices | awk 'NR > 1 && $2 == "device" { print $1 }') + + BOOTED_EMULATOR="${CONNECTED_DEVICES[0]}" +fi + +if [[ -n "${ANDROID_SERIAL:-}" ]]; then + ADB_SERIAL="$ANDROID_SERIAL" +elif [[ ${#CONNECTED_DEVICES[@]} -gt 1 ]]; then + ADB_SERIAL="${CONNECTED_DEVICES[0]}" + echo "Multiple devices detected; using $ADB_SERIAL. Set ANDROID_SERIAL to choose another device." >&2 +else + ADB_SERIAL="${CONNECTED_DEVICES[0]}" +fi + +if ! printf '%s\n' "${CONNECTED_DEVICES[@]}" | grep -Fxq "$ADB_SERIAL"; then + echo "Selected Android device '$ADB_SERIAL' is not connected and authorized" >&2 + adb devices -l >&2 exit 1 fi +export ANDROID_SERIAL="$ADB_SERIAL" +ADB=(adb -s "$ADB_SERIAL") + +wait_for_ready() { + local scenario="$1" + local elapsed=0 + while [[ $elapsed -lt $WAIT_TIMEOUT ]]; do + if "${ADB[@]}" logcat -d -s "$LOGCAT_TAG:I" 2>/dev/null | grep -qF "$scenario"; then + return 0 + fi + sleep 0.5 + elapsed=$((elapsed + 1)) + done + echo " Warning: timed out waiting for ready signal, capturing anyway" >&2 + return 0 +} + "$REPO_ROOT/gradlew" :merchant-terminal:installDebug for scenario in "${SCENARIOS[@]}"; do echo "Capturing $scenario" - adb shell am force-stop "$APP_ID" >/dev/null 2>&1 || true - adb shell am start -W -n "$ACTIVITY" --es "$EXTRA_KEY" "$scenario" >/dev/null - sleep 2 - adb exec-out screencap -p > "$OUT_DIR/$scenario.png" + "${ADB[@]}" shell am force-stop "$APP_ID" >/dev/null 2>&1 || true + "${ADB[@]}" logcat -c 2>/dev/null || true + "${ADB[@]}" shell am start -W -n "$ACTIVITY" --es "$EXTRA_KEY" "$scenario" >/dev/null + wait_for_ready "$scenario" "$WAIT_TIMEOUT" + sleep 3 + "${ADB[@]}" exec-out screencap -p > "$OUT_DIR/$scenario.png" done echo "Saved screenshots to $OUT_DIR" diff --git a/merchant-terminal/scripts/generate-screenshot-product-images.sh b/merchant-terminal/scripts/generate-screenshot-product-images.sh @@ -20,12 +20,48 @@ magick -size 640x640 xc:'#dff1e2' \ -fill '#3c6b48' -gravity south -font "$FONT_PATH" -pointsize 54 -annotate +0+48 'Tea' \ "$ASSET_DIR/tea.png" +magick -size 640x640 xc:'#fff4d8' \ + -fill '#f2c84b' -draw 'roundrectangle 220,150 420,455 38,38' \ + -fill '#fff9df' -draw 'rectangle 245,190 395,230' \ + -fill '#df8f2d' -draw 'circle 320,300 370,300' \ + -fill '#815c13' -gravity south -font "$FONT_PATH" -pointsize 54 -annotate +0+48 'Juice' \ + "$ASSET_DIR/juice.png" + +magick -size 640x640 xc:'#ead8c8' \ + -fill '#ffffff' -draw 'roundrectangle 230,180 410,430 22,22' \ + -fill '#3b2118' -draw 'ellipse 320,250 72,42 0,360' \ + -fill '#6b3f2c' -draw 'ellipse 320,255 54,28 0,360' \ + -fill '#5a3224' -gravity south -font "$FONT_PATH" -pointsize 50 -annotate +0+48 'Espresso' \ + "$ASSET_DIR/espresso.png" + +magick -size 640x640 xc:'#fff1c9' \ + -fill '#f5d34f' -draw 'roundrectangle 210,165 430,455 44,44' \ + -fill '#fff8d8' -draw 'rectangle 240,210 400,250' \ + -fill '#77a556' -draw 'line 360,150 320,210' \ + -fill '#7a6415' -gravity south -font "$FONT_PATH" -pointsize 48 -annotate +0+48 'Lemonade' \ + "$ASSET_DIR/lemonade.png" + magick -size 640x640 xc:'#f6e7d7' \ -fill '#c8843b' -draw 'path \"M 150,360 Q 320,120 490,360 Q 320,470 150,360 z\"' \ -fill '#e3ad62' -draw 'path \"M 210,335 Q 320,190 430,335 Q 320,405 210,335 z\"' \ -fill '#80511f' -gravity south -font "$FONT_PATH" -pointsize 54 -annotate +0+48 'Croissant' \ "$ASSET_DIR/croissant.png" +magick -size 640x640 xc:'#efe2f2' \ + -fill '#c18a43' -draw 'circle 320,305 455,305' \ + -fill '#e5bd77' -draw 'circle 320,285 420,285' \ + -fill '#4a65a4' -draw 'circle 270,250 288,250' \ + -fill '#4a65a4' -draw 'circle 350,275 370,275' \ + -fill '#6a3b79' -gravity south -font "$FONT_PATH" -pointsize 52 -annotate +0+48 'Muffin' \ + "$ASSET_DIR/muffin.png" + +magick -size 640x640 xc:'#f1e0c9' \ + -fill '#b87938' -draw 'ellipse 320,300 170,120 0,360' \ + -fill '#f5d7a6' -draw 'ellipse 320,300 80,55 0,360' \ + -fill '#f9f0df' -draw 'ellipse 320,300 52,34 0,360' \ + -fill '#73502a' -gravity south -font "$FONT_PATH" -pointsize 54 -annotate +0+48 'Bagel' \ + "$ASSET_DIR/bagel.png" + magick -size 640x640 xc:'#efe7dc' \ -fill '#c9914e' -draw 'roundrectangle 140,210 500,450 36,36' \ -fill '#7ebc6f' -draw 'roundrectangle 165,250 475,300 25,25' \ @@ -33,3 +69,54 @@ magick -size 640x640 xc:'#efe7dc' \ -fill '#f4deb2' -draw 'roundrectangle 165,345 475,410 24,24' \ -fill '#70491e' -gravity south -font "$FONT_PATH" -pointsize 50 -annotate +0+48 'Sandwich' \ "$ASSET_DIR/sandwich.png" + +magick -size 640x640 xc:'#eee5d7' \ + -fill '#d6b070' -draw 'polygon 230,170 440,250 360,455 170,380' \ + -fill '#87b96d' -draw 'polygon 255,220 400,275 345,390 210,345' \ + -fill '#f7e3b7' -draw 'polygon 275,260 380,295 335,360 235,330' \ + -fill '#6d4a21' -gravity south -font "$FONT_PATH" -pointsize 54 -annotate +0+48 'Wrap' \ + "$ASSET_DIR/wrap.png" + +magick -size 640x640 xc:'#e8f4e2' \ + -fill '#78aa55' -draw 'ellipse 320,325 180,110 0,360' \ + -fill '#b6d26d' -draw 'circle 255,290 282,290' \ + -fill '#d86f4a' -draw 'circle 355,310 380,310' \ + -fill '#f0d487' -draw 'circle 315,350 340,350' \ + -fill '#456d2e' -gravity south -font "$FONT_PATH" -pointsize 54 -annotate +0+48 'Salad' \ + "$ASSET_DIR/salad.png" + +magick -size 640x640 xc:'#f5e4d4' \ + -fill '#e38a34' -draw 'ellipse 320,320 180,95 0,360' \ + -fill '#f5b05a' -draw 'ellipse 320,300 140,65 0,360' \ + -fill '#f9e4b0' -draw 'circle 270,285 285,285' \ + -fill '#8a4a1c' -gravity south -font "$FONT_PATH" -pointsize 54 -annotate +0+48 'Soup' \ + "$ASSET_DIR/soup.png" + +magick -size 640x640 xc:'#f5ead8' \ + -fill '#c88a3d' -draw 'path \"M 190,390 A 150,150 0 0 1 450,240 L 450,390 z\"' \ + -fill '#f0d091' -draw 'path \"M 240,365 A 110,110 0 0 1 420,260 L 420,365 z\"' \ + -fill '#75a65d' -draw 'circle 335,310 352,310' \ + -fill '#82521f' -gravity south -font "$FONT_PATH" -pointsize 52 -annotate +0+48 'Quiche' \ + "$ASSET_DIR/quiche.png" + +magick -size 640x640 xc:'#edf2f6' \ + -fill '#ffffff' -draw 'roundrectangle 210,170 430,450 36,36' \ + -fill '#d7b36a' -draw 'rectangle 235,285 405,405' \ + -fill '#c74e58' -draw 'circle 280,250 302,250' \ + -fill '#4b6fae' -draw 'circle 345,240 365,240' \ + -fill '#49535f' -gravity south -font "$FONT_PATH" -pointsize 50 -annotate +0+48 'Granola' \ + "$ASSET_DIR/granola.png" + +magick -size 640x640 xc:'#edf0d8' \ + -fill '#cda94e' -draw 'polygon 210,210 370,250 300,430' \ + -fill '#dfbf66' -draw 'polygon 315,190 460,285 345,430' \ + -fill '#8d7425' -gravity south -font "$FONT_PATH" -pointsize 54 -annotate +0+48 'Chips' \ + "$ASSET_DIR/chips.png" + +magick -size 640x640 xc:'#f1edf6' \ + -fill '#ffffff' -draw 'roundrectangle 220,170 420,450 36,36' \ + -fill '#f0c158' -draw 'circle 285,275 315,275' \ + -fill '#d95b5b' -draw 'circle 350,275 378,275' \ + -fill '#79a85f' -draw 'circle 320,340 350,340' \ + -fill '#5c5570' -gravity south -font "$FONT_PATH" -pointsize 54 -annotate +0+48 'Fruit' \ + "$ASSET_DIR/fruit.png" diff --git a/merchant-terminal/src/debug/AndroidManifest.xml b/merchant-terminal/src/debug/AndroidManifest.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:android="http://schemas.android.com/apk/res/android"> + <application> + <activity + android:name="net.taler.merchantpos.debug.ScreenshotActivity" + android:exported="true" + android:screenOrientation="landscape" + android:theme="@style/AppTheme.NoActionBar" /> + </application> +</manifest> diff --git a/merchant-terminal/src/debug/java/net/taler/merchantpos/debug/ScreenshotActivity.kt b/merchant-terminal/src/debug/java/net/taler/merchantpos/debug/ScreenshotActivity.kt @@ -0,0 +1,753 @@ +package net.taler.merchantpos.debug + +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.Log +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import net.taler.common.Amount +import net.taler.common.Challenge +import net.taler.common.TanChannel +import net.taler.common.Timestamp +import net.taler.merchantlib.OrderHistoryEntry +import net.taler.merchantpos.MainViewModel +import net.taler.merchantpos.R +import net.taler.merchantpos.compose.PosTheme +import net.taler.merchantpos.config.InitialOrderScreen +import net.taler.merchantpos.payment.Payment +import net.taler.merchantpos.refund.RefundResult + +class ScreenshotActivity : AppCompatActivity() { + + companion object { + private const val TAG = "SCREENSHOT_READY" + private const val EXTRA_SCENARIO = "screenshot_scenario" + private const val CURRENCY = "CHF" + } + + private val model: MainViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + WindowCompat.setDecorFitsSystemWindows(window, false) + WindowInsetsControllerCompat(window, window.decorView).apply { + hide(WindowInsetsCompat.Type.systemBars()) + systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + + androidx.appcompat.app.AppCompatDelegate.setDefaultNightMode( + androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_NO, + ) + + val scenario = intent.getStringExtra(EXTRA_SCENARIO) ?: return + + val fixture = ScreenshotController.buildFixture(this) + model.configManager.debugApplyFixture( + posConfig = fixture.posConfig, + merchantConfig = fixture.merchantConfig, + currency = CURRENCY, + initialOrderScreen = InitialOrderScreen.Inventory, + ) + + setContent { + PosTheme(darkTheme = false) { + when (scenario) { + "login" -> WithSettingsTopBar { LoginScreen() } + "mfa-select" -> Box(modifier = Modifier.fillMaxSize()) { + WithSettingsTopBar { LoginScreen() } + MfaOverlay { MfaSelectContent() } + } + "mfa-code" -> Box(modifier = Modifier.fillMaxSize()) { + WithSettingsTopBar { LoginScreen() } + MfaOverlay { MfaCodeContent() } + } + "amount-entry" -> WithTopBar(stringResource(R.string.menu_amount_entry)) { + AmountEntryScreenshot() + } + "order" -> WithOrderTopBar { + OrderScreenshot(showCustomDialog = false) + } + "order-custom" -> WithOrderTopBar { + OrderScreenshot(showCustomDialog = true) + } + "order-custom-added" -> WithOrderTopBar { + OrderScreenshot(showCustomDialog = false) + } + "payment" -> WithTopBar(stringResource(R.string.payment_process_label)) { + PaymentScreenshot() + } + "payment-success" -> WithTopBar(stringResource(R.string.payment_received)) { + PaymentSuccessScreenshot() + } + "history" -> WithHistoryTopBar { + HistoryScreenshot() + } + "refund" -> WithTopBar(stringResource(R.string.history_refund)) { + RefundScreenshot() + } + "refund-qr" -> WithTopBar(stringResource(R.string.history_refund)) { + RefundQrScreenshot() + } + "navigation" -> NavigationDrawerScreenshot() + } + } + } + + when (scenario) { + "order", "order-custom", "order-custom-added", "navigation" -> seedOrder(scenario) + "payment" -> seedPayment() + "history" -> seedHistory() + "refund", "refund-qr" -> seedRefund(scenario) + } + + signalReady(scenario) + } + + private fun signalReady(scenario: String) { + Handler(Looper.getMainLooper()).postDelayed({ + Log.i(TAG, scenario) + }, 300) + } + + private fun seedOrder(scenario: String) { + model.orderManager.debugSeedCurrentOrder( + listOf( + "lemonade", "lemonade", "lemonade", "lemonade", "lemonade", + "wrap", "wrap", + "chips", + "salad", "salad", + ), + ) + if (scenario == "order-custom-added") { + val orderId = model.orderManager.currentOrderId.value ?: 0 + val customProduct = net.taler.merchantpos.config.ConfigProduct( + description = "Tip", + price = Amount.fromString(CURRENCY, "2.73"), + categories = listOf(Int.MIN_VALUE), + ) + model.orderManager.addProduct(orderId, customProduct) + } + } + + private fun seedPayment() { + val currentOrderId = model.orderManager.currentOrderId.value ?: 0 + val paymentProduct = net.taler.merchantpos.config.ConfigProduct( + description = "Order total", + price = Amount.fromString(CURRENCY, "123.45"), + categories = listOf(Int.MIN_VALUE), + ) + model.orderManager.addProduct(currentOrderId, paymentProduct) + val currentOrder = model.orderManager.getOrder(currentOrderId).order.value + ?: net.taler.merchantpos.order.Order( + id = currentOrderId, + currency = CURRENCY, + currencySpec = null, + availableCategories = emptyMap(), + ) + model.paymentManager.debugSetPayment( + Payment( + order = currentOrder, + summary = currentOrder.summary, + currency = CURRENCY, + orderId = "2026-ORD-1042", + talerPayUri = "taler://pay/example.invalid/2026-ORD-1042/ZXCVBNM123456", + ), + ) + } + + private fun seedHistory() { + val now = System.currentTimeMillis() + val hour = 3_600_000L + model.historyManager.debugSetHistory( + listOf( + OrderHistoryEntry( + orderId = "2026-ORD-1048", + rowId = 48, + timestamp = Timestamp.fromMillis(now - 1 * hour), + amount = Amount.fromString(CURRENCY, "22.00"), + summary = "5x Lemonade, 2x Chicken Wrap", + paid = false, + refundable = false, + ), + OrderHistoryEntry( + orderId = "2026-ORD-1047", + rowId = 47, + timestamp = Timestamp.fromMillis(now - 2 * hour), + amount = Amount.fromString(CURRENCY, "18.30"), + summary = "2x House Coffee, Veggie Sandwich", + paid = true, + refundable = true, + ), + OrderHistoryEntry( + orderId = "2026-ORD-1045", + rowId = 45, + timestamp = Timestamp.fromMillis(now - 5 * hour), + amount = Amount.fromString(CURRENCY, "9.80"), + summary = "Market Salad", + paid = true, + refundable = true, + refunded = true, + refundAmount = Amount.fromString(CURRENCY, "9.80"), + ), + OrderHistoryEntry( + orderId = "2026-ORD-1044", + rowId = 44, + timestamp = Timestamp.fromMillis(now - 7 * hour), + amount = Amount.fromString(CURRENCY, "14.60"), + summary = "Blueberry Muffin, Granola Cup, Espresso", + paid = true, + refundable = true, + refunded = true, + refundAmount = Amount.fromString(CURRENCY, "4.60"), + ), + OrderHistoryEntry( + orderId = "2026-ORD-1042", + rowId = 42, + timestamp = Timestamp.fromMillis(now - 26 * hour), + amount = Amount.fromString(CURRENCY, "123.45"), + summary = "Catering order", + paid = true, + refundable = true, + ), + OrderHistoryEntry( + orderId = "2026-ORD-1040", + rowId = 40, + timestamp = Timestamp.fromMillis(now - 50 * hour), + amount = Amount.fromString(CURRENCY, "7.20"), + summary = "Pumpkin Soup", + paid = true, + refundable = false, + ), + ), + ) + } + + private fun seedRefund(scenario: String) { + val refundItem = OrderHistoryEntry( + orderId = "2026-ORD-1047", + rowId = 47, + timestamp = Timestamp.now(), + amount = Amount.fromString(CURRENCY, "18.30"), + summary = "2x House Coffee, Veggie Sandwich", + paid = true, + refundable = true, + ) + if (scenario == "refund") { + model.refundManager.startRefund(refundItem) + } else { + model.refundManager.debugSetRefundResult( + RefundResult.Success( + refundUri = "taler://refund/example.invalid/2026-ORD-1047/REFUND-ABC123", + item = refundItem, + amount = Amount.fromString(CURRENCY, "18.30"), + reason = "Wrong order", + ), + ) + } + } + + // ── Top bar scaffolds ─────────────────────────────────────────────── + + @Composable + private fun WithTopBar(title: String, content: @Composable () -> Unit) { + Scaffold( + topBar = { + Surface(shadowElevation = 2.dp) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = {}) { + Icon(Icons.Default.Menu, contentDescription = null) + } + Text( + text = title, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.titleLarge, + ) + } + } + }, + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + content() + } + } + } + + @Composable + private fun WithOrderTopBar(content: @Composable () -> Unit) { + Scaffold( + topBar = { + Surface(shadowElevation = 2.dp) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = {}) { + Icon(Icons.Default.Menu, contentDescription = null) + } + Text( + text = stringResource(R.string.menu_order), + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.titleLarge, + ) + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + TopBarButton(stringResource(R.string.order_restart)) + TopBarButton(stringResource(R.string.order_previous)) + TopBarButton(stringResource(R.string.order_next)) + TopBarButton(stringResource(R.string.menu_reload)) + } + } + } + }, + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + content() + } + } + } + + @Composable + private fun WithHistoryTopBar(content: @Composable () -> Unit) { + Scaffold( + topBar = { + Surface(shadowElevation = 2.dp) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = {}) { + Icon(Icons.Default.Menu, contentDescription = null) + } + Text( + text = stringResource(R.string.menu_history), + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.titleLarge, + ) + TopBarButton(stringResource(R.string.history_refresh)) + } + } + }, + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + content() + } + } + } + + @Composable + private fun WithSettingsTopBar(content: @Composable () -> Unit) { + Scaffold( + topBar = { + Surface(shadowElevation = 2.dp) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + androidx.compose.foundation.layout.Spacer( + modifier = Modifier.size(48.dp), + ) + Text( + text = stringResource(R.string.config_label), + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.titleLarge, + ) + } + } + }, + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + content() + } + } + } + + @Composable + private fun TopBarButton(text: String, enabled: Boolean = true) { + Button( + onClick = {}, + enabled = enabled, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ), + modifier = Modifier.heightIn(min = 40.dp), + ) { + Text(text) + } + } + + // ── Screenshot composables ────────────────────────────────────────── + + @Composable + private fun LoginScreen() { + net.taler.merchantpos.config.ConfigScreenContent( + merchantUrl = "backend.demo.taler.net", + username = "sandbox", + token = "sandbox", + passwordVisible = true, + ) + } + + @Composable + private fun MfaSelectContent() { + Text( + text = stringResource(net.taler.common.R.string.mfa_choose_title), + style = MaterialTheme.typography.headlineSmall, + ) + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + fixtureChallenges.forEach { challenge -> + Button( + onClick = {}, + modifier = Modifier.fillMaxWidth(), + ) { + Text("${challenge.tanChannel}: ${challenge.tanInfo}") + } + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + ) { + OutlinedButton(onClick = {}) { + Text(stringResource(android.R.string.cancel)) + } + } + } + + @Composable + private fun MfaCodeContent() { + Text( + text = stringResource(net.taler.common.R.string.mfa_challenge_title), + style = MaterialTheme.typography.headlineSmall, + ) + Text( + text = stringResource( + net.taler.common.R.string.mfa_challenge_message, + fixtureChallenges.first().tanChannel.name, + fixtureChallenges.first().tanInfo, + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + MfaCodeDisplay(code = "77912082") + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + ) { + OutlinedButton(onClick = {}) { + Text(stringResource(android.R.string.cancel)) + } + Button(onClick = {}) { + Text(stringResource(android.R.string.ok)) + } + } + } + + @Composable + private fun AmountEntryScreenshot() { + net.taler.merchantpos.amount.AmountEntryScreenContent( + amountText = "123.45", + selectedCurrency = CURRENCY, + currencyOptions = listOf(CURRENCY), + chargeEnabled = true, + ) + } + + @Composable + private fun OrderScreenshot(showCustomDialog: Boolean) { + net.taler.merchantpos.order.OrderScreenContent( + viewModel = model, + showCustomDialog = showCustomDialog, + customDescription = if (showCustomDialog) "Tip" else null, + customAmount = if (showCustomDialog) "2.73" else null, + ) + } + + @Composable + private fun PaymentScreenshot() { + val payment = model.paymentManager.payment.value ?: return + net.taler.merchantpos.payment.PaymentScreenContent(payment = payment) + } + + @Composable + private fun PaymentSuccessScreenshot() { + net.taler.merchantpos.payment.PaymentSuccessScreenContent() + } + + @Composable + private fun HistoryScreenshot() { + net.taler.merchantpos.history.HistoryScreenContent( + items = (model.historyManager.items.value + as? net.taler.merchantpos.history.HistoryResult.Success)?.items.orEmpty(), + ) + } + + @Composable + private fun RefundScreenshot() { + val item = model.refundManager.toBeRefunded ?: return + net.taler.merchantpos.refund.RefundScreenContent( + item = item, + currencySpec = model.configManager.currencySpec, + initialReason = "Wrong order", + ) + } + + @Composable + private fun RefundQrScreenshot() { + val result = model.refundManager.refundResult.value as? RefundResult.Success ?: return + net.taler.merchantpos.refund.RefundQrScreenContent(result = result) + } + + @Composable + private fun NavigationDrawerScreenshot() { + Box(modifier = Modifier.fillMaxSize()) { + WithOrderTopBar { + OrderScreenshot(showCustomDialog = false) + } + // Scrim + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.32f)), + ) + // Drawer sheet + androidx.compose.material3.ModalDrawerSheet { + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + androidx.compose.foundation.Image( + painter = androidx.compose.ui.res.painterResource(R.drawable.ic_talerpos_logo), + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .height(72.dp) + .padding(horizontal = 8.dp, vertical = 8.dp), + ) + drawerItems.forEach { (destination, iconResId, labelResId) -> + androidx.compose.material3.NavigationDrawerItem( + icon = { + Icon( + painter = androidx.compose.ui.res.painterResource(iconResId), + contentDescription = null, + ) + }, + label = { + Text( + text = stringResource(labelResId), + fontWeight = FontWeight.SemiBold, + ) + }, + selected = destination == "order", + onClick = {}, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + colors = androidx.compose.material3.NavigationDrawerItemDefaults.colors(), + ) + } + } + } + } + } + + private data class DrawerItem( + val route: String, + val iconResId: Int, + val labelResId: Int, + ) + + private val drawerItems = listOf( + DrawerItem("order", R.drawable.ic_move_money_24dp, R.string.menu_order), + DrawerItem("amount-entry", R.drawable.ic_dialpad, R.string.menu_amount_entry), + DrawerItem("history", R.drawable.ic_history_black_24dp, R.string.menu_history), + DrawerItem("settings", R.drawable.ic_menu_manage, R.string.menu_settings), + ) + + // ── MFA visual helpers ────────────────────────────────────────────── + + @Composable + private fun MfaOverlay(content: @Composable () -> Unit) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.32f)), + contentAlignment = Alignment.Center, + ) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + shape = RoundedCornerShape(28.dp), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 6.dp, + shadowElevation = 6.dp, + ) { + Column( + modifier = Modifier.padding(24.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + content() + } + } + } + } + + @Composable + private fun MfaCodeDisplay(code: String) { + val interactionSource = remember { MutableInteractionSource() } + BasicTextField( + value = code, + onValueChange = {}, + modifier = Modifier.fillMaxWidth(), + textStyle = TextStyle(color = Color.Transparent), + cursorBrush = SolidColor(Color.Transparent), + readOnly = true, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword), + interactionSource = interactionSource, + decorationBox = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + repeat(8) { index -> + if (index == 4) { + Text( + text = "-", + modifier = Modifier.padding(horizontal = 3.dp), + color = MaterialTheme.colorScheme.primary, + fontSize = 22.sp, + fontWeight = FontWeight.Bold, + ) + } + MfaDigitBox(digit = code.getOrNull(index)) + } + } + }, + ) + } + + @Composable + private fun MfaDigitBox(digit: Char?) { + Box( + modifier = Modifier + .padding(horizontal = 1.dp) + .size(width = 30.dp, height = 52.dp), + contentAlignment = Alignment.Center, + ) { + Surface( + modifier = Modifier.matchParentSize(), + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + ) {} + Text( + text = digit?.toString().orEmpty(), + color = MaterialTheme.colorScheme.onSurface, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + ) + } + } + + private val fixtureChallenges = listOf( + Challenge( + challengeId = "CHALL-001", + tanChannel = TanChannel.EMAIL, + tanInfo = "s****x@demo.taler.net", + ), + Challenge( + challengeId = "CHALL-002", + tanChannel = TanChannel.SMS, + tanInfo = "+41 ** *** **42", + ), + ) +} diff --git a/merchant-terminal/src/debug/java/net/taler/merchantpos/debug/ScreenshotController.kt b/merchant-terminal/src/debug/java/net/taler/merchantpos/debug/ScreenshotController.kt @@ -1,121 +1,221 @@ package net.taler.merchantpos.debug import android.content.Context -import android.content.Intent import android.util.Base64 import net.taler.common.Amount +import net.taler.common.Tax import net.taler.merchantlib.MerchantConfig -import net.taler.merchantpos.MainViewModel -import net.taler.merchantpos.PosDestination import net.taler.merchantpos.config.Category import net.taler.merchantpos.config.ConfigProduct -import net.taler.merchantpos.config.InitialOrderScreen import net.taler.merchantpos.config.PosConfig -import net.taler.merchantpos.order.Order object ScreenshotController { - private const val EXTRA_SCENARIO = "taler_pos_screenshot_scenario" - private const val FIXTURE_ASSET_DIR = "screenshot-products" - private const val CURRENCY = "CHF" - - @Volatile - private var activeScenario: Scenario? = null - val isActive: Boolean - get() = activeScenario != null + const val isActive: Boolean = false - fun prepareScenario(intent: Intent, model: MainViewModel): PosDestination? { - val scenario = intent.getStringExtra(EXTRA_SCENARIO) - ?.let(Scenario::fromValue) - ?: run { - activeScenario = null - return null - } - activeScenario = scenario - applyScenario(model, scenario) - return scenario.destination - } - - private fun applyScenario(model: MainViewModel, scenario: Scenario) { - val fixture = buildFixture(model.getApplication()) - model.configManager.debugApplyFixture( - posConfig = fixture.posConfig, - merchantConfig = fixture.merchantConfig, - currency = CURRENCY, - initialOrderScreen = InitialOrderScreen.Inventory, - ) - model.orderManager.debugSeedCurrentOrder( - listOf("coffee", "croissant", "sandwich", "coffee"), - ) - val currentOrderId = model.orderManager.currentOrderId.value ?: 0 - val currentOrder = model.orderManager.getOrder(currentOrderId).order.value - ?: Order( - id = currentOrderId, - currency = CURRENCY, - currencySpec = null, - availableCategories = emptyMap(), - ) - - when (scenario) { - Scenario.AmountEntry -> Unit - Scenario.Order -> Unit - Scenario.Payment -> { - model.paymentManager.debugSetPayment( - net.taler.merchantpos.payment.Payment( - order = currentOrder, - summary = currentOrder.summary, - currency = CURRENCY, - orderId = "2026-ORD-1042", - talerPayUri = "taler://pay/example.invalid/2026-ORD-1042/ZXCVBNM123456", - ), - ) - } - Scenario.PaymentSuccess -> Unit - } - } + private const val FIXTURE_ASSET_DIR = "screenshot-products" + private const val CURRENCY = "CHF" - private fun buildFixture(context: Context): FixtureData { + fun buildFixture(context: Context): FixtureData { val categories = listOf( 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"), ) val products = listOf( ConfigProduct( id = "coffee", productId = "coffee", productName = "House Coffee", - description = "House Coffee", + description = "Freshly brewed fair-trade blend", price = Amount.fromString(CURRENCY, "3.80"), categories = listOf(1), image = assetDataUri(context, "coffee.png"), + taxes = setOf(vat("0.29")), + totalStock = 12, + totalSold = 4, ), ConfigProduct( id = "tea", productId = "tea", productName = "Herbal Tea", - description = "Herbal Tea", + description = "Mint and lemon verbena infusion", price = Amount.fromString(CURRENCY, "3.40"), categories = listOf(1), image = assetDataUri(context, "tea.png"), + taxes = setOf(vat("0.26")), + totalStock = 18, + totalSold = 6, + ), + ConfigProduct( + id = "juice", + productId = "juice", + productName = "Apple Juice", + description = "Pressed Swiss apples, 30 cl", + price = Amount.fromString(CURRENCY, "4.20"), + categories = listOf(1), + image = assetDataUri(context, "juice.png"), + taxes = setOf(vat("0.32")), + totalStock = 9, + totalSold = 1, + ), + ConfigProduct( + id = "espresso", + productId = "espresso", + productName = "Espresso", + description = "Single origin, short pull", + price = Amount.fromString(CURRENCY, "3.20"), + categories = listOf(1), + image = assetDataUri(context, "espresso.png"), + taxes = setOf(vat("0.24")), + totalStock = 20, + totalSold = 8, + ), + ConfigProduct( + id = "lemonade", + productId = "lemonade", + productName = "Lemonade", + description = "Sparkling lemon and ginger", + price = Amount.fromString(CURRENCY, "4.40"), + categories = listOf(1, 5), + image = assetDataUri(context, "lemonade.png"), + taxes = setOf(vat("0.33")), + totalStock = 14, + totalSold = 4, ), ConfigProduct( id = "croissant", productId = "croissant", productName = "Butter Croissant", - description = "Butter Croissant", + description = "All-butter pastry baked this morning", price = Amount.fromString(CURRENCY, "2.90"), categories = listOf(2), image = assetDataUri(context, "croissant.png"), + taxes = setOf(vat("0.22")), + totalStock = 16, + totalSold = 7, + ), + ConfigProduct( + id = "muffin", + productId = "muffin", + productName = "Blueberry Muffin", + description = "With lemon crumble topping", + price = Amount.fromString(CURRENCY, "4.60"), + categories = listOf(2, 4), + image = assetDataUri(context, "muffin.png"), + taxes = setOf(vat("0.35")), + totalStock = 10, + totalSold = 3, + ), + ConfigProduct( + id = "bagel", + productId = "bagel", + productName = "Sesame Bagel", + description = "Toasted with cream cheese", + price = Amount.fromString(CURRENCY, "5.20"), + categories = listOf(2), + image = assetDataUri(context, "bagel.png"), + taxes = setOf(vat("0.39")), + totalStock = 9, + totalSold = 2, ), ConfigProduct( id = "sandwich", productId = "sandwich", productName = "Veggie Sandwich", - description = "Veggie Sandwich", + description = "Grilled vegetables, hummus, seeded roll", price = Amount.fromString(CURRENCY, "8.50"), categories = listOf(3), image = assetDataUri(context, "sandwich.png"), + taxes = setOf(vat("0.64")), + totalStock = 8, + totalSold = 2, + ), + ConfigProduct( + id = "wrap", + productId = "wrap", + productName = "Chicken Wrap", + description = "Herbs, salad, yogurt dressing", + price = Amount.fromString(CURRENCY, "8.90"), + categories = listOf(3), + image = assetDataUri(context, "wrap.png"), + taxes = setOf(vat("0.67")), + totalStock = 7, + totalSold = 3, + ), + ConfigProduct( + id = "salad", + productId = "salad", + productName = "Market Salad", + description = "Greens, grains, roasted seeds", + price = Amount.fromString(CURRENCY, "9.80"), + categories = listOf(3, 5), + image = assetDataUri(context, "salad.png"), + taxes = setOf(vat("0.74")), + totalStock = 6, + totalSold = 1, + ), + ConfigProduct( + id = "soup", + productId = "soup", + productName = "Pumpkin Soup", + description = "Seasonal special, served warm", + price = Amount.fromString(CURRENCY, "7.20"), + categories = listOf(3, 5), + image = assetDataUri(context, "soup.png"), + taxes = setOf(vat("0.54")), + totalStock = 5, + totalSold = 5, + ), + ConfigProduct( + id = "quiche", + productId = "quiche", + productName = "Vegetable Quiche", + description = "Daily special with garden vegetables", + price = Amount.fromString(CURRENCY, "7.90"), + categories = listOf(3, 5), + image = assetDataUri(context, "quiche.png"), + taxes = setOf(vat("0.60")), + totalStock = 6, + totalSold = 2, + ), + ConfigProduct( + id = "granola", + productId = "granola", + productName = "Granola Cup", + description = "Yogurt, berries, toasted oats", + price = Amount.fromString(CURRENCY, "5.70"), + categories = listOf(4, 5), + image = assetDataUri(context, "granola.png"), + taxes = setOf(vat("0.43")), + totalStock = 7, + totalSold = 2, + ), + ConfigProduct( + id = "chips", + productId = "chips", + productName = "Lentil Chips", + description = "Sea salt snack bag", + price = Amount.fromString(CURRENCY, "3.60"), + categories = listOf(4), + image = assetDataUri(context, "chips.png"), + taxes = setOf(vat("0.27")), + totalStock = 15, + totalSold = 5, + ), + ConfigProduct( + id = "fruit", + productId = "fruit", + productName = "Fruit Cup", + description = "Seasonal sliced fruit", + price = Amount.fromString(CURRENCY, "4.90"), + categories = listOf(4, 5), + image = assetDataUri(context, "fruit.png"), + taxes = setOf(vat("0.37")), + totalStock = 8, + totalSold = 2, ), ) return FixtureData( @@ -133,23 +233,13 @@ object ScreenshotController { return "data:image/png;base64,$encoded" } - private data class FixtureData( + private fun vat(amount: String) = Tax( + name = "VAT", + tax = Amount.fromString(CURRENCY, amount), + ) + + data class FixtureData( val posConfig: PosConfig, val merchantConfig: MerchantConfig, ) - - private enum class Scenario( - val value: String, - val destination: PosDestination, - ) { - AmountEntry("amount-entry", PosDestination.AmountEntry), - Order("order", PosDestination.Order), - Payment("payment", PosDestination.ProcessPayment), - PaymentSuccess("payment-success", PosDestination.PaymentSuccess), - ; - - companion object { - fun fromValue(value: String): Scenario? = entries.firstOrNull { it.value == value } - } - } } diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/MainActivity.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/MainActivity.kt @@ -89,11 +89,9 @@ import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import kotlinx.coroutines.launch import net.taler.lib.android.TalerNfcService -import net.taler.merchantpos.debug.ScreenshotController import net.taler.merchantpos.compose.PosTheme import net.taler.merchantpos.config.Config import net.taler.merchantpos.config.ConfigFetcherFragment -import net.taler.merchantpos.config.ConfigUpdateResult import net.taler.merchantpos.config.ConfigFragment import net.taler.merchantpos.config.GeneralSettingsFragment import net.taler.merchantpos.history.HistoryFragment @@ -121,8 +119,6 @@ class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val screenshotStartDestination = ScreenshotController.prepareScenario(intent, model) - TalerNfcService.startService(this) model.paymentManager.payment.observe(this) { payment -> @@ -142,7 +138,7 @@ class MainActivity : AppCompatActivity() { PosTheme { MerchantTerminalApp( viewModel = model, - startDestination = determineStartDestination(screenshotStartDestination), + startDestination = determineStartDestination(), onNavControllerReady = { navController = it }, onExitRequested = ::handleExitRequest, ) @@ -226,7 +222,7 @@ class MainActivity : AppCompatActivity() { } val instance = pathStyleInstance ?: params?.get("username") ?: return - val token = if (pathStyleInstance != null) { + val token = if (pathStyleInstance != null && params == null) { Uri.decode(rawFragment).takeIf(String::isNotBlank) } else { params?.get("password") @@ -248,23 +244,10 @@ class MainActivity : AppCompatActivity() { Log.d("MainActivity", "Config URL: $merchantUrl") model.configManager.config = newConfig - model.configManager.fetchConfig(newConfig, true) navigateTo(PosDestination.ConfigFetcher) - - model.configManager.configUpdateResult.observe(this) { result -> - if (result is ConfigUpdateResult.Success) { - Log.d("MainActivity", "Config loaded successfully") - model.configManager.configUpdateResult.removeObservers(this) - } else if (result is ConfigUpdateResult.Error) { - Log.e("MainActivity", "Config failed: ${result.msg}") - model.configManager.configUpdateResult.removeObservers(this) - showPosError(result.msg) - } - } } - private fun determineStartDestination(overrideDestination: PosDestination? = null): PosDestination { - overrideDestination?.let { return it } + private fun determineStartDestination(): PosDestination { return when { !model.configManager.config.isValid() -> PosDestination.Config model.configManager.merchantConfig == null || model.configManager.currency == null -> diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/amount/AmountEntryFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/amount/AmountEntryFragment.kt @@ -543,3 +543,24 @@ private fun KeyButton( ) } } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun AmountEntryScreenContent( + amountText: String, + selectedCurrency: String, + currencyOptions: List<String>, + chargeEnabled: Boolean, +) { + AmountEntryScreen( + amountText = amountText, + selectedCurrency = selectedCurrency, + currencyOptions = currencyOptions, + chargeEnabled = chargeEnabled, + onCurrencySelected = {}, + onDigitPressed = {}, + onClearPressed = {}, + onBackspacePressed = {}, + onChargePressed = {}, + ) +} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/config/ConfigFetcherFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/config/ConfigFetcherFragment.kt @@ -60,7 +60,7 @@ class ConfigFetcherFragment : Fragment() { override fun onViewCreated(view: android.view.View, savedInstanceState: Bundle?) { if (configManager.config.savePassword()) { - configManager.fetchConfig(configManager.config, false) + configManager.fetchConfig(configManager.config, true) } else if (!navigating) { navigating = true (requireActivity() as MainActivity).navigateTo(PosDestination.Config, clearBackStack = true) diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/config/ConfigFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/config/ConfigFragment.kt @@ -465,6 +465,7 @@ private fun ConfigScreen( onSaveTokenChanged: (Boolean) -> Unit, previewContent: @Composable () -> Unit, onConnect: () -> Unit, + initialPasswordVisible: Boolean = false, ) { PosTheme { val isTabletLayout = LocalConfiguration.current.smallestScreenWidthDp >= 720 @@ -505,6 +506,7 @@ private fun ConfigScreen( onConnect = onConnect, focusManager = focusManager, keyboardController = keyboardController, + initialPasswordVisible = initialPasswordVisible, ) } else { if (isTabletLayout) { @@ -562,6 +564,7 @@ private fun ManualConfigScreen( onConnect: () -> Unit, focusManager: androidx.compose.ui.focus.FocusManager, keyboardController: androidx.compose.ui.platform.SoftwareKeyboardController?, + initialPasswordVisible: Boolean = false, ) { LazyColumn( modifier = modifier, @@ -599,7 +602,7 @@ private fun ManualConfigScreen( ) } item { - var passwordVisible by remember { mutableStateOf(false) } + var passwordVisible by remember { mutableStateOf(initialPasswordVisible) } OutlinedTextField( value = token, onValueChange = onTokenChanged, @@ -752,3 +755,29 @@ private fun RowButtons( } } } + +@Composable +internal fun ConfigScreenContent( + merchantUrl: String, + username: String, + token: String, + passwordVisible: Boolean = false, +) { + ConfigScreen( + mode = ConfigMode.Manual, + merchantUrl = merchantUrl, + username = username, + token = token, + saveToken = false, + isSubmitting = false, + isQrLoading = false, + onModeChanged = {}, + onMerchantUrlChanged = {}, + onUsernameChanged = {}, + onTokenChanged = {}, + onSaveTokenChanged = {}, + previewContent = {}, + onConnect = {}, + initialPasswordVisible = passwordVisible, + ) +} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/config/ConfigManager.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/config/ConfigManager.kt @@ -338,20 +338,15 @@ class ConfigManager( .toString() } - // get PoS configuration - val posConfig: PosConfig = httpClient.get(url) { - when (val c = config) { - is Config.New -> { - val token = "secret-token:${c.accessToken}" - val auth = ("Bearer $token") - header(Authorization, auth) - } - } - }.body() + val rawToken = when (val c = config) { + is Config.New -> c.accessToken + } + + val (posConfig, apiKey) = fetchPosConfig(url, rawToken) - val merchantConfig = when (val c = config) { + val merchantConfig = when (config) { //is Config.Old -> posConfig.merchantConfig!! - is Config.New -> MerchantConfig(c.merchantUrl, "secret-token:${c.accessToken}") + is Config.New -> MerchantConfig(config.merchantUrl, apiKey) } val backendConfig: MerchantBackendConfigResponse = @@ -380,6 +375,23 @@ class ConfigManager( } @WorkerThread + private suspend fun fetchPosConfig(url: String, rawToken: String): Pair<PosConfig, String> { + try { + val posConfig: PosConfig = httpClient.get(url) { + header(Authorization, "Bearer $rawToken") + }.body() + return posConfig to rawToken + } catch (e: ClientRequestException) { + if (e.response.status != Unauthorized || rawToken.startsWith("secret-token:")) throw e + } + val prefixedToken = "secret-token:$rawToken" + val posConfig: PosConfig = httpClient.get(url) { + header(Authorization, "Bearer $prefixedToken") + }.body() + return posConfig to prefixedToken + } + + @WorkerThread private suspend fun onMerchantConfigReceived( newConfig: Config?, posConfig: PosConfig, diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/history/HistoryFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/history/HistoryFragment.kt @@ -402,3 +402,22 @@ private fun ForceDeleteOrderDialog( }, ) } + +@Composable +internal fun HistoryScreenContent( + items: List<OrderHistoryEntry>, +) { + HistoryScreen( + isLoading = false, + isLoadingMore = false, + result = HistoryResult.Success(items), + pendingRefundOrderId = null, + activePayment = null, + onRefresh = {}, + onLoadMore = {}, + onRefundClicked = {}, + onDeleteClicked = {}, + onShowPaymentClicked = {}, + onShowRefundClicked = {}, + ) +} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/history/HistoryManager.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/history/HistoryManager.kt @@ -158,6 +158,13 @@ class HistoryManager( mItems.value = HistoryResult.Success(loadedItems.toList()) } + @UiThread + internal fun debugSetHistory(items: List<OrderHistoryEntry>) { + loadedItems.clear() + loadedItems.addAll(items) + publishItems() + } + private fun enrichOrders(items: List<OrderHistoryEntry>) = scope.launch { val merchantConfig = configManager.merchantConfig ?: return@launch items.filter { it.paid }.forEach { item -> diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/order/OrderFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/order/OrderFragment.kt @@ -155,7 +155,9 @@ private fun OrderRoute( var selectedCategoryId by rememberSaveable { mutableStateOf(categories.firstOrNull { it.selected }?.id) } - var showCustomDialog by rememberSaveable { mutableStateOf(false) } + var showCustomDialog by rememberSaveable { + mutableStateOf(false) + } val reloadingText = stringResource(R.string.toast_reloading) LaunchedEffect(Unit) { @@ -827,11 +829,13 @@ private fun CustomProductDialog( currencySpec: net.taler.common.CurrencySpecification?, onDismiss: () -> Unit, onAdd: (String, Amount) -> Unit, + initialDescription: String? = null, + initialAmount: String? = null, ) { val defaultDescription = stringResource(R.string.order_custom_product_default) val invalidAmountText = stringResource(R.string.refund_error_invalid_amount) - var description by rememberSaveable { mutableStateOf(defaultDescription) } - var amountText by rememberSaveable { mutableStateOf("") } + var description by rememberSaveable { mutableStateOf(initialDescription ?: defaultDescription) } + var amountText by rememberSaveable { mutableStateOf(initialAmount ?: "") } var errorText by rememberSaveable { mutableStateOf<String?>(null) } AlertDialog( @@ -875,3 +879,87 @@ private fun CustomProductDialog( }, ) } + +@Composable +internal fun OrderScreenContent( + viewModel: MainViewModel, + showCustomDialog: Boolean = false, + customDescription: String? = null, + customAmount: String? = null, +) { + val orderManager = remember { viewModel.orderManager } + val currentOrderId by orderManager.currentOrderId.observeAsState() + val categories by orderManager.categories.observeAsState(emptyList()) + val products by orderManager.products.observeAsState(emptyList()) + val currency = viewModel.configManager.currency + val currencySpec = viewModel.configManager.currencySpec + + val orderId = currentOrderId ?: return + val liveOrder = remember(orderId) { orderManager.getOrder(orderId) } + val order by liveOrder.order.observeAsState() + val orderTotal by liveOrder.orderTotal.observeAsState( + Amount.zero(currency ?: "").withSpec(currencySpec), + ) + val modifyAllowed by liveOrder.modifyOrderAllowed.observeAsState(false) + val increaseAllowed by liveOrder.increaseOrderAllowed.observeAsState(false) + var selectedProductKey by rememberSaveable(orderId) { mutableStateOf(liveOrder.selectedProductKey) } + var selectedCategoryId by rememberSaveable { + mutableStateOf(categories.firstOrNull { it.selected }?.id) + } + + if (showCustomDialog && currency != null) { + CustomProductDialog( + currency = currency, + currencySpec = currencySpec, + onDismiss = {}, + onAdd = { _, _ -> }, + initialDescription = customDescription, + initialAmount = customAmount, + ) + } + + PosTheme { + Box( + modifier = Modifier.fillMaxSize(), + ) { + val isTabletLayout = LocalConfiguration.current.smallestScreenWidthDp >= 720 + if (isTabletLayout) { + TabletOrderScreen( + categories = categories, + selectedCategoryId = selectedCategoryId, + products = products, + order = order, + increaseAllowed = increaseAllowed, + modifyAllowed = modifyAllowed, + orderTotal = orderTotal.toString(), + selectedProductKey = selectedProductKey, + onCategorySelected = { selectedCategoryId = it.id }, + onProductSelected = {}, + onSelectProduct = { selectedProductKey = it?.id }, + onIncrease = {}, + onDecrease = {}, + onAddCustom = {}, + onComplete = {}, + ) + } else { + PhoneOrderScreen( + categories = categories, + selectedCategoryId = selectedCategoryId, + products = products, + order = order, + increaseAllowed = increaseAllowed, + modifyAllowed = modifyAllowed, + orderTotal = orderTotal.toString(), + selectedProductKey = selectedProductKey, + onCategorySelected = { selectedCategoryId = it.id }, + onProductSelected = {}, + onSelectProduct = { selectedProductKey = it?.id }, + onIncrease = {}, + onDecrease = {}, + onAddCustom = {}, + onComplete = {}, + ) + } + } + } +} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/payment/PaymentSuccessFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/payment/PaymentSuccessFragment.kt @@ -134,3 +134,8 @@ private fun PaymentSuccessScreen( } } } + +@Composable +internal fun PaymentSuccessScreenContent() { + PaymentSuccessScreen(onContinue = {}) +} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/payment/ProcessPaymentFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/payment/ProcessPaymentFragment.kt @@ -445,3 +445,17 @@ private fun PhoneProcessPaymentScreen( } } } + +@Composable +internal fun PaymentScreenContent(payment: Payment) { + ProcessPaymentScreen( + payment = payment, + deviceHasNfc = false, + showForceDeleteDialog = false, + onCancel = {}, + onForceDelete = {}, + onDismissForceDelete = {}, + onShare = {}, + onCopy = {}, + ) +} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/refund/RefundFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/refund/RefundFragment.kt @@ -119,13 +119,14 @@ class RefundFragment : Fragment() { private fun RefundScreen( item: OrderHistoryEntry, currencySpec: net.taler.common.CurrencySpecification?, + initialReason: String? = null, onAbort: () -> Unit, onRefund: (OrderHistoryEntry, Amount, String) -> Unit, ) { var amountText by remember { mutableStateOf(item.amount.withSpec(currencySpec).amountStr) } - var reason by remember { mutableStateOf("") } + var reason by remember { mutableStateOf(initialReason ?: "") } var errorText by remember { mutableStateOf<String?>(null) } val amountFocusRequester = remember { FocusRequester() } val reasonFocusRequester = remember { FocusRequester() } @@ -236,3 +237,18 @@ private fun RefundScreen( } } } + +@Composable +internal fun RefundScreenContent( + item: OrderHistoryEntry, + currencySpec: net.taler.common.CurrencySpecification?, + initialReason: String? = null, +) { + RefundScreen( + item = item, + currencySpec = currencySpec, + initialReason = initialReason, + onAbort = {}, + onRefund = { _, _, _ -> }, + ) +} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/refund/RefundManager.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/refund/RefundManager.kt @@ -87,6 +87,11 @@ class RefundManager( } @UiThread + internal fun debugSetRefundResult(result: RefundResult) { + mRefundResult.value = result + } + + @UiThread internal fun resumeRefund(item: OrderHistoryEntry): Boolean { val current = mRefundResult.value as? RefundResult.Success ?: return false if (current.item.orderId != item.orderId) return false diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/refund/RefundUriFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/refund/RefundUriFragment.kt @@ -325,3 +325,12 @@ private fun PhoneRefundUriScreen( } } } + +@Composable +internal fun RefundQrScreenContent(result: RefundResult.Success) { + RefundUriScreen( + result = result, + deviceHasNfc = false, + onAbort = {}, + ) +} diff --git a/merchant-terminal/src/release/java/net/taler/merchantpos/debug/ScreenshotController.kt b/merchant-terminal/src/release/java/net/taler/merchantpos/debug/ScreenshotController.kt @@ -1,12 +1,5 @@ package net.taler.merchantpos.debug -import android.content.Intent -import net.taler.merchantpos.MainViewModel -import net.taler.merchantpos.PosDestination - object ScreenshotController { - val isActive: Boolean - get() = false - - fun prepareScenario(intent: Intent, model: MainViewModel): PosDestination? = null + const val isActive: Boolean = false } diff --git a/taler-kotlin-android/src/main/java/net/taler/lib/android/MfaUtils.kt b/taler-kotlin-android/src/main/java/net/taler/lib/android/MfaUtils.kt @@ -160,7 +160,9 @@ suspend fun Fragment.selectChallenge(challenges: List<Challenge>): Challenge? = } } -suspend fun Fragment.promptForTan(challenge: Challenge): String? = +suspend fun Fragment.promptForTan( + challenge: Challenge, +): String? = showMfaDialog(cancelResult = null) { finish -> var code by remember { mutableStateOf("") } var showIncompleteError by remember { mutableStateOf(false) }