feat(android-terminal): Phase F — kiosk, immersive, queued-action toast
Some checks failed
Some checks failed
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:
@@ -70,6 +70,12 @@ dependencies {
|
||||
debugImplementation(libs.compose.ui.tooling)
|
||||
|
||||
// Networking (Retrofit + Moshi)
|
||||
// Note: Moshi 1.15.x prints a one-line "Kapt support … deprecated" warning
|
||||
// during hiltJavaCompileDebug because Hilt's javac compile auto-discovers
|
||||
// its annotation processor. It's purely cosmetic — KSP runs the codegen
|
||||
// and the warning has no effect on output. Cure (moshix) needed Kotlin
|
||||
// 2.1.21+, which would cascade into bumping KSP / Compose BOM. Living
|
||||
// with the warning until Moshi 2.0 ships KSP-native.
|
||||
implementation(libs.retrofit)
|
||||
implementation(libs.retrofit.moshi)
|
||||
implementation(libs.okhttp)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user