feat(android-terminal): Phase C — PIN screen with offline bcrypt verify
Some checks failed
CI / ruff (push) Successful in 15s
CI / pytest (push) Failing after 2h27m32s
CI / validate (push) Successful in 33s
CI / dependency-scanning (push) Successful in 32s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped

Two-pane landscape: scrollable staff list on the left, PIN dots + numeric
keypad on the right. Footer shows online/offline + pending-sync count.

Going with cached-hashes for offline-capable PIN verify (decision logged
in chat). The threat model already accepts the device — a stolen tablet
holds a 1-year store-scoped JWT, so leaking 4-digit bcrypt hashes is
incremental. Hashes only ever leave the server when the requester is a
paired POS tablet, gated by the new endpoint refusing user JWTs.

Backend:
- GET /api/v1/store/loyalty/pins/for-device — returns PINs WITH pin_hash
  for terminal-device JWTs only; user JWTs receive 403.
- PinForDeviceResponse / PinForDeviceListResponse schemas.
- 2 integration tests in TestPinsForDevice (10/10 pass total).

Android:
- PinForDeviceItem / PinForDeviceListResponse Moshi models.
- LoyaltyApi.listPinsForDevice().
- StaffPinRepository.verifyPin(plain) — at.favre.lib bcrypt verify
  against cached hashes; filters active + unlocked rows in one pass.
- PendingTransactionDao.getPendingCount() switched to Flow<Int> so the
  badge auto-updates when transactions sync.
- PinViewModel state machine — loads pins on init, accumulates digits,
  bcrypt-verifies on length >= 4, fires verified/errorMessage. Combines
  pending-sync count + online state into the same StateFlow.
- PinScreen rewrite: avatar-circle staff list, 6-dot PIN display,
  spinner during verify, error label on wrong PIN, status footer.

Open follow-up (intentional, post-launch): tablet doesn't yet report
failed attempts back to the server's lockout counter. Path is clear —
small POST /pins/{id}/record-failed-attempt endpoint plus a call from
attemptVerify's failure branch.

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-05 22:58:18 +02:00
parent a0e3461c48
commit 3bf23c1b23
10 changed files with 640 additions and 93 deletions

View File

@@ -30,6 +30,8 @@ from app.modules.loyalty.schemas import (
CardResponse, CardResponse,
MerchantStatsResponse, MerchantStatsResponse,
PinCreate, PinCreate,
PinForDeviceListResponse,
PinForDeviceResponse,
PinListResponse, PinListResponse,
PinResponse, PinResponse,
PinUpdate, 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) @router.post("/pins", response_model=PinResponse, status_code=201)
def create_pin( def create_pin(
data: PinCreate, data: PinCreate,

View File

@@ -42,6 +42,8 @@ from app.modules.loyalty.schemas.pin import (
PinCreateForMerchant, PinCreateForMerchant,
PinDetailListResponse, PinDetailListResponse,
PinDetailResponse, PinDetailResponse,
PinForDeviceListResponse,
PinForDeviceResponse,
PinListResponse, PinListResponse,
PinResponse, PinResponse,
PinUpdate, PinUpdate,
@@ -135,6 +137,8 @@ __all__ = [
"PinCreateForMerchant", "PinCreateForMerchant",
"PinUpdate", "PinUpdate",
"PinResponse", "PinResponse",
"PinForDeviceResponse",
"PinForDeviceListResponse",
"PinDetailResponse", "PinDetailResponse",
"PinListResponse", "PinListResponse",
"PinDetailListResponse", "PinDetailListResponse",

View File

@@ -99,6 +99,23 @@ class PinDetailListResponse(BaseModel):
total: int 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): class PinVerifyRequest(BaseModel):
"""Schema for verifying a staff PIN.""" """Schema for verifying a staff PIN."""

View File

@@ -257,3 +257,55 @@ class TestActingDeviceAudit:
) )
assert tx is not None assert tx is not None
assert tx.acting_terminal_device_id is 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

View File

@@ -44,6 +44,14 @@ interface LoyaltyApi {
@GET("api/v1/store/loyalty/pins") @GET("api/v1/store/loyalty/pins")
suspend fun listPins(): PinListResponse 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) // Categories (for the action sheets — multi-select pills)
@GET("api/v1/store/loyalty/categories") @GET("api/v1/store/loyalty/categories")
suspend fun listCategories(): CategoryListResponse suspend fun listCategories(): CategoryListResponse

View File

@@ -1,6 +1,7 @@
package lu.rewardflow.terminal.data.db.dao package lu.rewardflow.terminal.data.db.dao
import androidx.room.* import androidx.room.*
import kotlinx.coroutines.flow.Flow
import lu.rewardflow.terminal.data.db.entity.PendingTransaction import lu.rewardflow.terminal.data.db.entity.PendingTransaction
@Dao @Dao
@@ -12,8 +13,10 @@ interface PendingTransactionDao {
@Query("SELECT * FROM pending_transactions WHERE status = 'pending' ORDER BY createdAt ASC") @Query("SELECT * FROM pending_transactions WHERE status = 'pending' ORDER BY createdAt ASC")
suspend fun getPending(): List<PendingTransaction> suspend fun getPending(): List<PendingTransaction>
/** 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'") @Query("SELECT COUNT(*) FROM pending_transactions WHERE status = 'pending'")
suspend fun getPendingCount(): Int fun getPendingCount(): Flow<Int>
@Update @Update
suspend fun update(transaction: PendingTransaction) suspend fun update(transaction: PendingTransaction)

View File

@@ -190,6 +190,24 @@ data class PinItem(
val is_locked: Boolean, val is_locked: Boolean,
) )
@JsonClass(generateAdapter = true)
data class PinForDeviceListResponse(
val pins: List<PinForDeviceItem>,
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 ───────────────────────────────────────────────────────────── // ── Pairing ─────────────────────────────────────────────────────────────
/** /**

View File

@@ -1,24 +1,28 @@
package lu.rewardflow.terminal.data.repository package lu.rewardflow.terminal.data.repository
import at.favre.lib.crypto.bcrypt.BCrypt
import com.squareup.moshi.JsonAdapter import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.squareup.moshi.Types import com.squareup.moshi.Types
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import lu.rewardflow.terminal.data.api.LoyaltyApi 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.Inject
import javax.inject.Singleton import javax.inject.Singleton
/** /**
* Source of truth for staff PINs on the device. * Source of truth for staff PINs on the device.
* *
* Refreshes from ``GET /api/v1/store/loyalty/pins`` and caches the * Refreshes from ``GET /api/v1/store/loyalty/pins/for-device`` (which only
* serialized list in DataStore so the PIN screen can render before the * accepts paired-device JWTs and includes the bcrypt ``pin_hash``) and
* network call completes (and stay usable for the few seconds where the * caches the serialized list in DataStore so the PIN screen can render
* tablet has no signal between transactions). * 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 * [verifyPin] does the bcrypt comparison locally so the tablet doesn't
* repository is just the cache primitive. * 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 @Singleton
class StaffPinRepository @Inject constructor( class StaffPinRepository @Inject constructor(
@@ -27,28 +31,57 @@ class StaffPinRepository @Inject constructor(
moshi: Moshi, moshi: Moshi,
) { ) {
private val listAdapter: JsonAdapter<List<PinItem>> = moshi.adapter( private val listAdapter: JsonAdapter<List<PinForDeviceItem>> = moshi.adapter(
Types.newParameterizedType(List::class.java, PinItem::class.java) Types.newParameterizedType(List::class.java, PinForDeviceItem::class.java)
) )
/** Hit the server, persist the result, return the freshly fetched list. */ /** Hit the server, persist the result, return the freshly fetched list. */
suspend fun refresh(): List<PinItem> { suspend fun refresh(): List<PinForDeviceItem> {
val response = api.listPins() val response = api.listPinsForDevice()
val pins = response.pins val pins = response.pins
configRepository.saveStaffPins(listAdapter.toJson(pins)) configRepository.saveStaffPins(listAdapter.toJson(pins))
return pins return pins
} }
/** Last cached list. Empty if the device has never synced. */ /** Last cached list. Empty if the device has never synced. */
suspend fun cached(): List<PinItem> { suspend fun cached(): List<PinForDeviceItem> {
val raw = configRepository.staffPinsJson.first() ?: return emptyList() val raw = configRepository.staffPinsJson.first() ?: return emptyList()
return runCatching { listAdapter.fromJson(raw) ?: emptyList() } return runCatching { listAdapter.fromJson(raw) ?: emptyList() }
.getOrDefault(emptyList()) .getOrDefault(emptyList())
} }
/** Cached if available, otherwise hit the network. */ /** Cached if available, otherwise hit the network. */
suspend fun listOrRefresh(): List<PinItem> { suspend fun listOrRefresh(): List<PinForDeviceItem> {
val cached = cached() val cached = cached()
return cached.ifEmpty { refresh() } 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
}
} }

View File

@@ -1,103 +1,327 @@
package lu.rewardflow.terminal.ui.pin package lu.rewardflow.terminal.ui.pin
import androidx.compose.foundation.layout.* import androidx.compose.foundation.BorderStroke
import androidx.compose.material3.* import androidx.compose.foundation.background
import androidx.compose.runtime.* 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.Alignment
import androidx.compose.ui.Modifier 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.dp
import androidx.compose.ui.unit.sp 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). * Left: scrollable list of staff names (cached, refreshed on init).
* PINs are cached locally and refreshed periodically from the API. * 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 @Composable
fun PinScreen( fun PinScreen(
onPinVerified: (staffPinId: Int, staffName: String) -> Unit, onPinVerified: (staffPinId: Int, staffName: String) -> Unit,
viewModel: PinViewModel = hiltViewModel(),
) { ) {
var pinDigits by remember { mutableStateOf("") } val state by viewModel.state.collectAsStateWithLifecycle()
var error by remember { mutableStateOf<String?>(null) }
Box( LaunchedEffect(state.verified) {
modifier = Modifier.fillMaxSize(), val verified = state.verified
contentAlignment = Alignment.Center, if (verified != null) {
) { onPinVerified(verified.id, verified.name)
Column( viewModel.consumeVerified()
horizontalAlignment = Alignment.CenterHorizontally, }
modifier = Modifier.padding(48.dp), }
) {
Text( Column(modifier = Modifier.fillMaxSize().padding(24.dp)) {
text = "Enter Staff PIN", Row(modifier = Modifier.weight(1f).fillMaxWidth()) {
style = MaterialTheme.typography.headlineMedium, 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 @Composable
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { private fun StaffListPane(
repeat(4) { i -> modifier: Modifier,
Surface( pins: List<PinForDeviceItem>,
modifier = Modifier.size(56.dp), loading: Boolean,
shape = MaterialTheme.shapes.medium, ) {
color = if (pinDigits.length > i) Column(modifier = modifier) {
MaterialTheme.colorScheme.primary Text(
else text = stringResource(R.string.pin_select_staff),
MaterialTheme.colorScheme.surfaceVariant, style = MaterialTheme.typography.titleLarge,
) { )
Box(contentAlignment = Alignment.Center) { Spacer(Modifier.height(16.dp))
if (pinDigits.length > i) { when {
Text("", fontSize = 32.sp, color = MaterialTheme.colorScheme.onPrimary) loading -> {
} Box(
} modifier = Modifier.fillMaxSize(),
} contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
} }
} }
pins.isEmpty() -> {
error?.let { Box(
Spacer(modifier = Modifier.height(12.dp)) modifier = Modifier.fillMaxSize(),
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) contentAlignment = Alignment.Center,
} ) {
Text(
Spacer(modifier = Modifier.height(32.dp)) text = stringResource(R.string.pin_no_staff),
style = MaterialTheme.typography.bodyLarge,
// Number pad color = MaterialTheme.colorScheme.onSurfaceVariant,
val buttons = listOf( )
listOf("1", "2", "3"), }
listOf("4", "5", "6"), }
listOf("7", "8", "9"), else -> {
listOf("C", "0", ""), LazyColumn(verticalArrangement = Arrangement.spacedBy(12.dp)) {
) items(pins, key = { it.id }) { pin -> StaffRow(pin) }
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)
}
}
} }
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,
)
}
}

View File

@@ -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<PinUiState> = _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<PinForDeviceItem> = 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,
)