diff --git a/app/modules/loyalty/routes/api/store.py b/app/modules/loyalty/routes/api/store.py index 169a3203..148d907a 100644 --- a/app/modules/loyalty/routes/api/store.py +++ b/app/modules/loyalty/routes/api/store.py @@ -30,6 +30,8 @@ from app.modules.loyalty.schemas import ( CardResponse, MerchantStatsResponse, PinCreate, + PinForDeviceListResponse, + PinForDeviceResponse, PinListResponse, PinResponse, PinUpdate, @@ -346,6 +348,47 @@ def list_pins( ) +@router.get("/pins/for-device", response_model=PinForDeviceListResponse) +def list_pins_for_device( + current_user: User = Depends(get_current_store_api), + db: Session = Depends(get_db), +): + """List staff PINs *with* their bcrypt ``pin_hash`` for offline verify. + + Reserved for paired POS tablets — the ``Authorization`` header must + carry a device JWT. User JWTs are rejected with 403. Hashes only ever + leave the server when the requester has been blessed by a merchant + owner via the device pairing flow, and the device is the same source + of truth that can be revoked from /merchants/loyalty/devices. + """ + if current_user.terminal_device_id is None: + raise AuthorizationException( + "This endpoint is only available to paired POS terminal devices" + ) + + store_id = current_user.token_store_id + program = program_service.require_program_by_store(db, store_id) + pins = pin_service.list_pins(db, program.id, store_id=store_id) + + return PinForDeviceListResponse( + pins=[ + PinForDeviceResponse( + id=p.id, + name=p.name, + staff_id=p.staff_id, + is_active=p.is_active, + is_locked=p.is_locked, + locked_until=p.locked_until, + last_used_at=p.last_used_at, + created_at=p.created_at, + pin_hash=p.pin_hash, + ) + for p in pins + ], + total=len(pins), + ) + + @router.post("/pins", response_model=PinResponse, status_code=201) def create_pin( data: PinCreate, diff --git a/app/modules/loyalty/schemas/__init__.py b/app/modules/loyalty/schemas/__init__.py index e073df4a..ea27d6fa 100644 --- a/app/modules/loyalty/schemas/__init__.py +++ b/app/modules/loyalty/schemas/__init__.py @@ -42,6 +42,8 @@ from app.modules.loyalty.schemas.pin import ( PinCreateForMerchant, PinDetailListResponse, PinDetailResponse, + PinForDeviceListResponse, + PinForDeviceResponse, PinListResponse, PinResponse, PinUpdate, @@ -135,6 +137,8 @@ __all__ = [ "PinCreateForMerchant", "PinUpdate", "PinResponse", + "PinForDeviceResponse", + "PinForDeviceListResponse", "PinDetailResponse", "PinListResponse", "PinDetailListResponse", diff --git a/app/modules/loyalty/schemas/pin.py b/app/modules/loyalty/schemas/pin.py index c65b6e2f..6f3a7e23 100644 --- a/app/modules/loyalty/schemas/pin.py +++ b/app/modules/loyalty/schemas/pin.py @@ -99,6 +99,23 @@ class PinDetailListResponse(BaseModel): total: int +class PinForDeviceResponse(PinResponse): + """Pin response for a paired terminal device. + + Includes the bcrypt ``pin_hash`` so the tablet can verify a typed + PIN locally without a network round-trip. This shape is ONLY served + by the device-only endpoint (``GET /pins/for-device``) — every other + pin endpoint stays hash-less. + """ + + pin_hash: str + + +class PinForDeviceListResponse(BaseModel): + pins: list[PinForDeviceResponse] + total: int + + class PinVerifyRequest(BaseModel): """Schema for verifying a staff PIN.""" diff --git a/app/modules/loyalty/tests/integration/test_terminal_devices.py b/app/modules/loyalty/tests/integration/test_terminal_devices.py index 8c3975cb..349da8c9 100644 --- a/app/modules/loyalty/tests/integration/test_terminal_devices.py +++ b/app/modules/loyalty/tests/integration/test_terminal_devices.py @@ -257,3 +257,55 @@ class TestActingDeviceAudit: ) assert tx is not None assert tx.acting_terminal_device_id is None + + +@pytest.mark.integration +@pytest.mark.api +@pytest.mark.loyalty +class TestPinsForDevice: + """GET /pins/for-device exposes hashes only to paired devices.""" + + def test_device_token_receives_hashes( + self, client, loyalty_merchant_headers, loyalty_store_setup, db + ): + from app.modules.loyalty.models import StaffPin + + store = loyalty_store_setup["store"] + program = loyalty_store_setup["program"] + pin = StaffPin( + merchant_id=store.merchant_id, + program_id=program.id, + store_id=store.id, + name="Test Cashier", + staff_id="cash01", + ) + pin.set_pin("4321") + db.add(pin) + db.commit() + db.refresh(pin) + + paired = client.post( + f"{MERCHANT_BASE}/devices", + json={"store_id": store.id, "label": "PIN test"}, + headers=loyalty_merchant_headers, + ).json() + token = paired["setup_token"] + + response = client.get( + f"{STORE_BASE}/pins/for-device", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 200, response.text + payload = response.json() + assert payload["total"] >= 1 + target = next(p for p in payload["pins"] if p["id"] == pin.id) + assert target["pin_hash"].startswith("$2") and len(target["pin_hash"]) > 50 + + def test_user_token_is_rejected( + self, client, loyalty_store_headers + ): + response = client.get( + f"{STORE_BASE}/pins/for-device", + headers=loyalty_store_headers, + ) + assert response.status_code == 403, response.text diff --git a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/api/LoyaltyApi.kt b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/api/LoyaltyApi.kt index 827a2b23..be3e1dc1 100644 --- a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/api/LoyaltyApi.kt +++ b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/api/LoyaltyApi.kt @@ -44,6 +44,14 @@ interface LoyaltyApi { @GET("api/v1/store/loyalty/pins") suspend fun listPins(): PinListResponse + /** + * PINs with their bcrypt pin_hash. Only callable with a paired-device + * JWT — user JWTs receive 403. Used by the tablet to verify staff + * PINs offline. + */ + @GET("api/v1/store/loyalty/pins/for-device") + suspend fun listPinsForDevice(): PinForDeviceListResponse + // Categories (for the action sheets — multi-select pills) @GET("api/v1/store/loyalty/categories") suspend fun listCategories(): CategoryListResponse diff --git a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/db/dao/PendingTransactionDao.kt b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/db/dao/PendingTransactionDao.kt index 1ee52b87..660b24a8 100644 --- a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/db/dao/PendingTransactionDao.kt +++ b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/db/dao/PendingTransactionDao.kt @@ -1,6 +1,7 @@ package lu.rewardflow.terminal.data.db.dao import androidx.room.* +import kotlinx.coroutines.flow.Flow import lu.rewardflow.terminal.data.db.entity.PendingTransaction @Dao @@ -12,8 +13,10 @@ interface PendingTransactionDao { @Query("SELECT * FROM pending_transactions WHERE status = 'pending' ORDER BY createdAt ASC") suspend fun getPending(): List + /** Reactive count for the PIN-screen badge — re-emits whenever the + * queue changes (insert / update / delete). */ @Query("SELECT COUNT(*) FROM pending_transactions WHERE status = 'pending'") - suspend fun getPendingCount(): Int + fun getPendingCount(): Flow @Update suspend fun update(transaction: PendingTransaction) diff --git a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/model/ApiModels.kt b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/model/ApiModels.kt index 5342c795..c813b26d 100644 --- a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/model/ApiModels.kt +++ b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/model/ApiModels.kt @@ -190,6 +190,24 @@ data class PinItem( val is_locked: Boolean, ) +@JsonClass(generateAdapter = true) +data class PinForDeviceListResponse( + val pins: List, + val total: Int, +) + +@JsonClass(generateAdapter = true) +data class PinForDeviceItem( + val id: Int, + val name: String, + val staff_id: String? = null, + val is_active: Boolean = true, + val is_locked: Boolean = false, + /** bcrypt hash of the PIN — only ever returned by /pins/for-device, + * consumed by [PinVerifier]. */ + val pin_hash: String, +) + // ── Pairing ───────────────────────────────────────────────────────────── /** diff --git a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/repository/StaffPinRepository.kt b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/repository/StaffPinRepository.kt index 674fe376..452a6043 100644 --- a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/repository/StaffPinRepository.kt +++ b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/repository/StaffPinRepository.kt @@ -1,24 +1,28 @@ package lu.rewardflow.terminal.data.repository +import at.favre.lib.crypto.bcrypt.BCrypt import com.squareup.moshi.JsonAdapter import com.squareup.moshi.Moshi import com.squareup.moshi.Types import kotlinx.coroutines.flow.first import lu.rewardflow.terminal.data.api.LoyaltyApi -import lu.rewardflow.terminal.data.model.PinItem +import lu.rewardflow.terminal.data.model.PinForDeviceItem import javax.inject.Inject import javax.inject.Singleton /** * Source of truth for staff PINs on the device. * - * Refreshes from ``GET /api/v1/store/loyalty/pins`` and caches the - * serialized list in DataStore so the PIN screen can render before the - * network call completes (and stay usable for the few seconds where the - * tablet has no signal between transactions). + * Refreshes from ``GET /api/v1/store/loyalty/pins/for-device`` (which only + * accepts paired-device JWTs and includes the bcrypt ``pin_hash``) and + * caches the serialized list in DataStore so the PIN screen can render + * before the network call completes — and stay usable when the tablet has + * no signal at all. * - * The PIN-entry verification flow itself is wired in Phase C — this - * repository is just the cache primitive. + * [verifyPin] does the bcrypt comparison locally so the tablet doesn't + * round-trip the typed PIN to the server. Lockout state from the server + * (``is_locked``) is honored, but failed-attempt reporting back to the + * server's lockout machinery is a TODO for a follow-up. */ @Singleton class StaffPinRepository @Inject constructor( @@ -27,28 +31,57 @@ class StaffPinRepository @Inject constructor( moshi: Moshi, ) { - private val listAdapter: JsonAdapter> = moshi.adapter( - Types.newParameterizedType(List::class.java, PinItem::class.java) + private val listAdapter: JsonAdapter> = moshi.adapter( + Types.newParameterizedType(List::class.java, PinForDeviceItem::class.java) ) /** Hit the server, persist the result, return the freshly fetched list. */ - suspend fun refresh(): List { - val response = api.listPins() + suspend fun refresh(): List { + val response = api.listPinsForDevice() val pins = response.pins configRepository.saveStaffPins(listAdapter.toJson(pins)) return pins } /** Last cached list. Empty if the device has never synced. */ - suspend fun cached(): List { + suspend fun cached(): List { val raw = configRepository.staffPinsJson.first() ?: return emptyList() return runCatching { listAdapter.fromJson(raw) ?: emptyList() } .getOrDefault(emptyList()) } /** Cached if available, otherwise hit the network. */ - suspend fun listOrRefresh(): List { + suspend fun listOrRefresh(): List { val cached = cached() return cached.ifEmpty { refresh() } } + + /** + * Verify a typed PIN against the cached list. + * + * - Returns the matching PIN row when [plain] matches some active, + * unlocked entry's bcrypt hash. + * - Returns null on no match. The caller decides whether that maps to + * a "wrong PIN" toast or a "locked" message; check [PinForDeviceItem.is_locked] + * on candidates if you need to disambiguate. + * + * bcrypt verification is intentionally O(n_pins) — at typical store + * sizes (3-15 staff) that's a handful of ~250ms hashes which is fine + * for a one-shot login flow but not something to call in a tight loop. + */ + suspend fun verifyPin(plain: String): PinForDeviceItem? { + if (plain.isBlank()) return null + val pins = cached().ifEmpty { return null } + val plainChars = plain.toCharArray() + return pins.firstOrNull { pin -> + pin.is_active && !pin.is_locked && verifyHash(plainChars, pin.pin_hash) + } + } + + private fun verifyHash(plain: CharArray, hash: String): Boolean = try { + BCrypt.verifyer().verify(plain, hash.toCharArray()).verified + } catch (_: IllegalArgumentException) { + // Hash is malformed — log silently and treat as no match. + false + } } diff --git a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/pin/PinScreen.kt b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/pin/PinScreen.kt index 7f138579..3048de1b 100644 --- a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/pin/PinScreen.kt +++ b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/pin/PinScreen.kt @@ -1,103 +1,327 @@ package lu.rewardflow.terminal.ui.pin -import androidx.compose.foundation.layout.* -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import lu.rewardflow.terminal.R +import lu.rewardflow.terminal.data.model.PinForDeviceItem /** - * PIN entry screen — staff enters their 4-digit PIN to unlock the terminal. + * Two-pane landscape PIN entry. * - * The PIN identifies the staff member (unique per store). - * PINs are cached locally and refreshed periodically from the API. + * Left: scrollable list of staff names (cached, refreshed on init). + * Right: PIN dots + numeric keypad. Once the user has typed enough + * digits the view-model bcrypt-verifies against the cached list and + * fires [onPinVerified] via a [LaunchedEffect] watching the verified + * state. + * + * Footer carries the offline / pending-sync indicator so the cashier + * sees at a glance whether their next transaction will go through + * straight away or get queued. */ @Composable fun PinScreen( onPinVerified: (staffPinId: Int, staffName: String) -> Unit, + viewModel: PinViewModel = hiltViewModel(), ) { - var pinDigits by remember { mutableStateOf("") } - var error by remember { mutableStateOf(null) } + val state by viewModel.state.collectAsStateWithLifecycle() - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding(48.dp), - ) { - Text( - text = "Enter Staff PIN", - style = MaterialTheme.typography.headlineMedium, + LaunchedEffect(state.verified) { + val verified = state.verified + if (verified != null) { + onPinVerified(verified.id, verified.name) + viewModel.consumeVerified() + } + } + + Column(modifier = Modifier.fillMaxSize().padding(24.dp)) { + Row(modifier = Modifier.weight(1f).fillMaxWidth()) { + StaffListPane( + modifier = Modifier.weight(1f).fillMaxHeight(), + pins = state.pins, + loading = state.loading, ) - Spacer(modifier = Modifier.height(24.dp)) + Spacer(Modifier.size(24.dp)) + KeypadPane( + modifier = Modifier.weight(1f).fillMaxHeight(), + state = state, + onDigit = viewModel::onDigit, + onBackspace = viewModel::onBackspace, + onClear = viewModel::onClear, + ) + } + StatusFooter( + pendingSyncCount = state.pendingSyncCount, + isOnline = state.isOnline, + ) + } +} - // PIN dots display - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - repeat(4) { i -> - Surface( - modifier = Modifier.size(56.dp), - shape = MaterialTheme.shapes.medium, - color = if (pinDigits.length > i) - MaterialTheme.colorScheme.primary - else - MaterialTheme.colorScheme.surfaceVariant, - ) { - Box(contentAlignment = Alignment.Center) { - if (pinDigits.length > i) { - Text("•", fontSize = 32.sp, color = MaterialTheme.colorScheme.onPrimary) - } - } - } +@Composable +private fun StaffListPane( + modifier: Modifier, + pins: List, + loading: Boolean, +) { + Column(modifier = modifier) { + Text( + text = stringResource(R.string.pin_select_staff), + style = MaterialTheme.typography.titleLarge, + ) + Spacer(Modifier.height(16.dp)) + when { + loading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() } } - - error?.let { - Spacer(modifier = Modifier.height(12.dp)) - Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) - } - - Spacer(modifier = Modifier.height(32.dp)) - - // Number pad - val buttons = listOf( - listOf("1", "2", "3"), - listOf("4", "5", "6"), - listOf("7", "8", "9"), - listOf("C", "0", "⌫"), - ) - - buttons.forEach { row -> - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - row.forEach { label -> - FilledTonalButton( - onClick = { - when (label) { - "C" -> { pinDigits = ""; error = null } - "⌫" -> { if (pinDigits.isNotEmpty()) pinDigits = pinDigits.dropLast(1) } - else -> { - if (pinDigits.length < 4) { - pinDigits += label - if (pinDigits.length == 4) { - // TODO: verify PIN against cached list - // For now, placeholder callback - onPinVerified(1, "Staff") - } - } - } - } - }, - modifier = Modifier.size(72.dp), - ) { - Text(label, fontSize = 24.sp) - } - } + pins.isEmpty() -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.pin_no_staff), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + else -> { + LazyColumn(verticalArrangement = Arrangement.spacedBy(12.dp)) { + items(pins, key = { it.id }) { pin -> StaffRow(pin) } } - Spacer(modifier = Modifier.height(8.dp)) } } } } + +@Composable +private fun StaffRow(pin: PinForDeviceItem) { + val containerColor = when { + pin.is_locked -> MaterialTheme.colorScheme.errorContainer + !pin.is_active -> MaterialTheme.colorScheme.surfaceVariant + else -> MaterialTheme.colorScheme.surface + } + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant, + shape = RoundedCornerShape(12.dp), + ), + color = containerColor, + tonalElevation = 1.dp, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Text( + text = pin.name.firstOrNull()?.uppercase() ?: "?", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + Spacer(Modifier.size(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = pin.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + if (pin.staff_id != null) { + Text( + text = pin.staff_id, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (pin.is_locked) { + Text( + text = stringResource(R.string.pin_locked), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + } +} + +@Composable +private fun KeypadPane( + modifier: Modifier, + state: PinUiState, + onDigit: (Char) -> Unit, + onBackspace: () -> Unit, + onClear: () -> Unit, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = stringResource(R.string.pin_enter), + style = MaterialTheme.typography.titleLarge, + ) + Spacer(Modifier.height(20.dp)) + + // PIN dots — 6 wide so users see when their PIN exceeds the + // common 4-digit length. + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + repeat(6) { i -> + val filled = state.enteredDigits.length > i + val outlineOnly = i >= 4 && !filled + Surface( + modifier = Modifier.size(48.dp), + shape = RoundedCornerShape(10.dp), + color = when { + filled -> MaterialTheme.colorScheme.primary + outlineOnly -> Color.Transparent + else -> MaterialTheme.colorScheme.surfaceVariant + }, + border = if (outlineOnly) + BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) + else null, + ) { + Box(contentAlignment = Alignment.Center) { + if (filled) { + Text( + text = "•", + fontSize = 28.sp, + color = MaterialTheme.colorScheme.onPrimary, + ) + } + } + } + } + } + + if (state.errorMessage != null) { + Spacer(Modifier.height(10.dp)) + Text( + text = stringResource( + when (state.errorMessage) { + ErrorReason.WrongPin -> R.string.pin_wrong + ErrorReason.Locked -> R.string.pin_locked + ErrorReason.Unknown -> R.string.error_unknown + } + ), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + ) + } + + if (state.verifying) { + Spacer(Modifier.height(10.dp)) + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + } + + Spacer(Modifier.height(28.dp)) + + val rows = listOf( + listOf("1", "2", "3"), + listOf("4", "5", "6"), + listOf("7", "8", "9"), + listOf("C", "0", "⌫"), + ) + rows.forEach { row -> + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + row.forEach { label -> + FilledTonalButton( + onClick = { + when (label) { + "C" -> onClear() + "⌫" -> onBackspace() + else -> onDigit(label[0]) + } + }, + modifier = Modifier.size(76.dp), + contentPadding = PaddingValues(0.dp), + ) { + Text(label, fontSize = 24.sp) + } + } + } + Spacer(Modifier.height(10.dp)) + } + } +} + +@Composable +private fun StatusFooter( + pendingSyncCount: Int, + isOnline: Boolean, +) { + Spacer(Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(if (isOnline) R.string.terminal_online else R.string.terminal_offline), + style = MaterialTheme.typography.labelMedium, + color = if (isOnline) + MaterialTheme.colorScheme.primary + else + MaterialTheme.colorScheme.error, + ) + Text( + text = if (pendingSyncCount == 0) + stringResource(R.string.pin_all_synced) + else + stringResource(R.string.pin_pending_sync, pendingSyncCount), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} diff --git a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/pin/PinViewModel.kt b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/pin/PinViewModel.kt new file mode 100644 index 00000000..2f7f8b78 --- /dev/null +++ b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/pin/PinViewModel.kt @@ -0,0 +1,145 @@ +package lu.rewardflow.terminal.ui.pin + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import lu.rewardflow.terminal.data.db.dao.PendingTransactionDao +import lu.rewardflow.terminal.data.model.PinForDeviceItem +import lu.rewardflow.terminal.data.network.NetworkMonitor +import lu.rewardflow.terminal.data.repository.StaffPinRepository +import javax.inject.Inject + +/** + * Powers the PIN-entry screen. + * + * The screen always renders the cached staff list (so it works offline) + * and a numeric keypad. As the user types, [enteredDigits] grows; once + * it hits the configured length the view-model bcrypt-verifies against + * the cached hashes and emits a [VerifiedPin] which the screen consumes + * to navigate. + * + * Lockout state from the server (``is_locked`` on the cached row) is + * honored — locked PINs simply never match. Reporting failed attempts + * BACK to the server's lockout counter is deferred to a follow-up. + */ +@HiltViewModel +class PinViewModel @Inject constructor( + private val staffPinRepository: StaffPinRepository, + private val pendingTransactionDao: PendingTransactionDao, + networkMonitor: NetworkMonitor, +) : ViewModel() { + + private val _state = MutableStateFlow(PinUiState()) + val state: StateFlow = _state.asStateFlow() + + init { + loadPins() + + // Wire pending-sync count + online badge into the state flow so the + // view doesn't have to subscribe to multiple flows itself. + combine( + pendingTransactionDao.getPendingCount(), + networkMonitor.isOnline, + ) { pending, online -> pending to online } + .onEach { (pending, online) -> + _state.value = _state.value.copy( + pendingSyncCount = pending, + isOnline = online, + ) + } + .launchIn(viewModelScope) + } + + fun loadPins() { + viewModelScope.launch { + _state.value = _state.value.copy(loading = true, errorMessage = null) + // Cached if available; if empty, attempt a refresh. + val pins = runCatching { staffPinRepository.listOrRefresh() } + .getOrDefault(emptyList()) + _state.value = _state.value.copy(loading = false, pins = pins) + } + } + + fun onDigit(digit: Char) { + if (!digit.isDigit()) return + val current = _state.value + if (current.verifying || current.verified != null) return + val next = (current.enteredDigits + digit).take(MAX_PIN_LENGTH) + _state.value = current.copy(enteredDigits = next, errorMessage = null) + if (next.length >= MIN_PIN_LENGTH) { + attemptVerify(next) + } + } + + fun onBackspace() { + val current = _state.value + if (current.verifying) return + _state.value = current.copy( + enteredDigits = current.enteredDigits.dropLast(1), + errorMessage = null, + ) + } + + fun onClear() { + if (_state.value.verifying) return + _state.value = _state.value.copy(enteredDigits = "", errorMessage = null) + } + + fun consumeVerified() { + _state.value = _state.value.copy(verified = null, enteredDigits = "") + } + + private fun attemptVerify(plain: String) { + _state.value = _state.value.copy(verifying = true) + viewModelScope.launch { + val match = runCatching { staffPinRepository.verifyPin(plain) } + .getOrNull() + _state.value = if (match != null) { + _state.value.copy( + verifying = false, + verified = VerifiedPin(id = match.id, name = match.name), + ) + } else { + _state.value.copy( + verifying = false, + enteredDigits = "", + errorMessage = ErrorReason.WrongPin, + ) + } + } + } + + companion object { + private const val MIN_PIN_LENGTH = 4 + private const val MAX_PIN_LENGTH = 6 + } +} + +data class PinUiState( + val loading: Boolean = true, + val pins: List = emptyList(), + val enteredDigits: String = "", + val errorMessage: ErrorReason? = null, + val verifying: Boolean = false, + val verified: VerifiedPin? = null, + val pendingSyncCount: Int = 0, + val isOnline: Boolean = true, +) + +enum class ErrorReason { + WrongPin, + Locked, + Unknown, +} + +data class VerifiedPin( + val id: Int, + val name: String, +)