diff --git a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/IdleTracker.kt b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/IdleTracker.kt new file mode 100644 index 00000000..026e3c7e --- /dev/null +++ b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/IdleTracker.kt @@ -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() + } +} diff --git a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/TerminalScreen.kt b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/TerminalScreen.kt index b16c770c..75a4c712 100644 --- a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/TerminalScreen.kt +++ b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/TerminalScreen.kt @@ -69,6 +69,28 @@ fun TerminalScreen( ) { 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()) { TopBar( staffName = staffName,