feat(android-terminal): Phase D.5 — auto-lock idle timer
Some checks failed
CI / ruff (push) Successful in 19s
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

Wraps the terminal screen in an IdleTracker that observes pointer-down
events and fires onLockScreen after 2 min of silence (per spec). Each
tap restarts the timer via a LaunchedEffect re-launch.

awaitFirstDown(requireUnconsumed = false) lets us observe touches
without intercepting them, so children (text fields, buttons, lists,
keypad) keep working normally. Body extracted into a private
TerminalContent so the wrapper stays tidy.

Caveat: Compose AlertDialogs render in a separate window, so the timer
keeps ticking through an open dialog. This is correct behavior — a
cashier who walks away mid-dialog should be locked out — and the
dialog disposes when the NavHost pops the terminal route on lock.
Documented in IdleTracker.kt.

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-06 21:31:16 +02:00
parent 01a12dcef4
commit d3f1c33b37
2 changed files with 78 additions and 0 deletions

View File

@@ -0,0 +1,56 @@
package lu.rewardflow.terminal.ui.terminal
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import kotlinx.coroutines.delay
/**
* Wraps a screen and fires [onIdle] after [timeoutMillis] of no pointer
* activity. Each touch-down restarts the timer. Disposing the wrapper
* cancels the pending timer.
*
* Used by the terminal screen to auto-lock back to PIN after 2 minutes
* of inactivity, per the implementation plan. Camera flows (QR scanner
* overlay) don't generate pointer events, but those flows are short-
* lived and the user is clearly using the device anyway — we accept
* the trade-off rather than wire a separate "in-active-flow" guard.
*/
@Composable
fun IdleTracker(
timeoutMillis: Long,
onIdle: () -> Unit,
content: @Composable () -> Unit,
) {
var lastActivity by remember { mutableLongStateOf(System.currentTimeMillis()) }
LaunchedEffect(lastActivity) {
delay(timeoutMillis)
onIdle()
}
Box(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
awaitEachGesture {
// requireUnconsumed=false → observe even when a child
// consumes the gesture (otherwise we'd miss every tap
// on a button / text field, which is most of them).
awaitFirstDown(requireUnconsumed = false)
lastActivity = System.currentTimeMillis()
}
},
) {
content()
}
}

View File

@@ -69,6 +69,28 @@ fun TerminalScreen(
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
IdleTracker(
timeoutMillis = AUTO_LOCK_TIMEOUT_MS,
onIdle = onLockScreen,
) {
TerminalContent(
state = state,
staffName = staffName,
onLockScreen = onLockScreen,
viewModel = viewModel,
)
}
}
private const val AUTO_LOCK_TIMEOUT_MS = 2 * 60 * 1000L // 2 minutes per spec
@Composable
private fun TerminalContent(
state: TerminalUiState,
staffName: String,
onLockScreen: () -> Unit,
viewModel: TerminalViewModel,
) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
TopBar( TopBar(
staffName = staffName, staffName = staffName,