feat(android-terminal): Phase F — kiosk, immersive, queued-action toast
Some checks failed
CI / ruff (push) Successful in 14s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled

Closes out the Android terminal plan (Phases A → F).

- MainActivity:
  * Immersive mode via WindowInsetsControllerCompat — hides status +
    navigation bars, swipe-to-reveal with auto-hide
    (BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE). Re-applied in onResume so
    a transient overlay can't leave the bars visible.
  * Lock Task Mode — startLockTask() in onResume. The manifest already
    declares lockTaskMode="if_whitelisted", so this enters kiosk on a
    properly MDM-provisioned tablet and is a silent no-op (caught
    SecurityException) on dev / unprovisioned devices.

- TerminalScreen:
  * SnackbarHost pinned at the bottom; LaunchedEffect(actionResult)
    shows "Queued — will sync when back online" whenever an offline
    action gets queued, so the cashier has explicit feedback beyond the
    top-bar pending pill.
  * 4 new locale strings (en/fr/de/lb) for the snackbar copy.

- Manifest cleanup: dropped redundant tools:replace from the debug
  AndroidManifest overlay (networkSecurityConfig isn't set in the main
  manifest, so the replace directive was a no-op + emitted a warning).

Skipped from the plan: a custom splash screen (the existing theme
background renders for the cold-start frame; adding the splashscreen
library is polish for a follow-up) and per-action success toasts
(the action sheet closing + balance refresh + recent-feed update are
adequate confirmation).

Verified by ./gradlew assembleDebug — clean build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 00:20:46 +02:00
parent ac5f46cff3
commit c1d367bac2
8 changed files with 101 additions and 4 deletions

View File

@@ -1,10 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Debug-only manifest overlay. Merged onto the main manifest by AGP for
debug builds; release builds skip this and keep the platform default
(no cleartext HTTP). -->
<application
android:networkSecurityConfig="@xml/network_security_config"
tools:replace="android:networkSecurityConfig" />
android:networkSecurityConfig="@xml/network_security_config" />
</manifest>

View File

@@ -1,6 +1,9 @@
package lu.rewardflow.terminal
import android.app.ActivityManager
import android.content.Context
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
@@ -8,6 +11,9 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import dagger.hilt.android.AndroidEntryPoint
import lu.rewardflow.terminal.ui.RewardFlowNavHost
import lu.rewardflow.terminal.ui.theme.RewardFlowTheme
@@ -18,6 +24,7 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
configureImmersiveMode()
setContent {
RewardFlowTheme {
@@ -30,4 +37,47 @@ class MainActivity : ComponentActivity() {
}
}
}
override fun onResume() {
super.onResume()
// Re-apply immersive each time we come back from a transient overlay
// (action sheets, system dialogs) — they sometimes restore the bars.
configureImmersiveMode()
enterLockTaskIfPossible()
}
/** Hide both the status bar and the navigation bar; reveal them on
* swipe and auto-hide again. Standard Android "immersive sticky"
* behavior, expressed via the Compat APIs. */
private fun configureImmersiveMode() {
WindowCompat.setDecorFitsSystemWindows(window, false)
val controller = WindowInsetsControllerCompat(window, window.decorView)
controller.hide(WindowInsetsCompat.Type.systemBars())
controller.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
/**
* Promote the activity to Lock Task Mode (kiosk).
*
* Manifest has ``android:lockTaskMode="if_whitelisted"``, so this
* call is only honored when a Device Owner / DPC app has whitelisted
* our package. In dev — and on stock retail tablets without MDM
* provisioning — the call is a silent no-op, which is the right
* behavior. On a properly provisioned tablet, this prevents the
* cashier from leaving the app via Home / Recents / notifications.
*/
private fun enterLockTaskIfPossible() {
val am = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
if (am.lockTaskModeState == ActivityManager.LOCK_TASK_MODE_NONE) {
try {
startLockTask()
} catch (e: SecurityException) {
// Whitelist isn't in place — fine, run as a regular app.
Log.d("MainActivity", "Lock task not whitelisted: ${e.message}")
} catch (e: IllegalStateException) {
Log.d("MainActivity", "Cannot enter lock task: ${e.message}")
}
}
}
}

View File

@@ -24,11 +24,16 @@ import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -95,6 +100,15 @@ private fun TerminalContent(
onLockScreen: () -> Unit,
viewModel: TerminalViewModel,
) {
val snackbarHostState = remember { SnackbarHostState() }
val queuedMessage = stringResource(R.string.snackbar_queued_for_sync)
LaunchedEffect(state.actionResult) {
if (state.actionResult is ActionResult.Queued) {
snackbarHostState.showSnackbar(message = queuedMessage)
}
}
Column(modifier = Modifier.fillMaxSize()) {
TopBar(
staffName = staffName,
@@ -155,6 +169,19 @@ private fun TerminalContent(
onCancel = viewModel::dismissScanner,
)
}
// Snackbar host pinned at the bottom — always present so it can fire
// over any of the dialogs/overlays above.
Box(modifier = Modifier.fillMaxSize()) {
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(16.dp),
) { data ->
Snackbar(snackbarData = data)
}
}
}
@Composable

View File

@@ -72,4 +72,8 @@
<string name="generic_loading">Wird geladen…</string>
<string name="generic_yes">Ja</string>
<string name="generic_no">Nein</string>
<!-- Sync -->
<string name="snackbar_queued_for_sync">In der Warteschlange — synchronisiert, sobald wieder online</string>
<string name="snackbar_dismiss">Schließen</string>
</resources>

View File

@@ -72,4 +72,8 @@
<string name="generic_loading">Chargement…</string>
<string name="generic_yes">Oui</string>
<string name="generic_no">Non</string>
<!-- Sync -->
<string name="snackbar_queued_for_sync">En file d\'attente — synchronisation au retour de la connexion</string>
<string name="snackbar_dismiss">Fermer</string>
</resources>

View File

@@ -72,4 +72,8 @@
<string name="generic_loading">Lueden…</string>
<string name="generic_yes">Jo</string>
<string name="generic_no">Nee</string>
<!-- Sync -->
<string name="snackbar_queued_for_sync">An der Schlaang — synchroniséiert sou bal nees online</string>
<string name="snackbar_dismiss">Zoumaachen</string>
</resources>

View File

@@ -72,4 +72,8 @@
<string name="generic_loading">Loading…</string>
<string name="generic_yes">Yes</string>
<string name="generic_no">No</string>
<!-- Sync -->
<string name="snackbar_queued_for_sync">Queued — will sync when back online</string>
<string name="snackbar_dismiss">Dismiss</string>
</resources>