feat(android-terminal): Phase C — PIN screen with offline bcrypt verify
Some checks failed
Some checks failed
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:
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<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'")
|
||||
suspend fun getPendingCount(): Int
|
||||
fun getPendingCount(): Flow<Int>
|
||||
|
||||
@Update
|
||||
suspend fun update(transaction: PendingTransaction)
|
||||
|
||||
@@ -190,6 +190,24 @@ data class PinItem(
|
||||
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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<List<PinItem>> = moshi.adapter(
|
||||
Types.newParameterizedType(List::class.java, PinItem::class.java)
|
||||
private val listAdapter: JsonAdapter<List<PinForDeviceItem>> = 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<PinItem> {
|
||||
val response = api.listPins()
|
||||
suspend fun refresh(): List<PinForDeviceItem> {
|
||||
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<PinItem> {
|
||||
suspend fun cached(): List<PinForDeviceItem> {
|
||||
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<PinItem> {
|
||||
suspend fun listOrRefresh(): List<PinForDeviceItem> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String?>(null) }
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
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.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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StaffListPane(
|
||||
modifier: Modifier,
|
||||
pins: List<PinForDeviceItem>,
|
||||
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,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(48.dp),
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
pins.isEmpty() -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "Enter Staff PIN",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
text = stringResource(R.string.pin_no_staff),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
items(pins, key = { it.id }) { pin -> StaffRow(pin) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PIN dots display
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
repeat(4) { i ->
|
||||
@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.size(56.dp),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = if (pinDigits.length > i)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else
|
||||
MaterialTheme.colorScheme.surfaceVariant,
|
||||
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 (pinDigits.length > i) {
|
||||
Text("•", fontSize = 32.sp, color = MaterialTheme.colorScheme.onPrimary)
|
||||
if (filled) {
|
||||
Text(
|
||||
text = "•",
|
||||
fontSize = 28.sp,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
error?.let {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
if (state.verifying) {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
}
|
||||
|
||||
// Number pad
|
||||
val buttons = listOf(
|
||||
Spacer(Modifier.height(28.dp))
|
||||
|
||||
val rows = listOf(
|
||||
listOf("1", "2", "3"),
|
||||
listOf("4", "5", "6"),
|
||||
listOf("7", "8", "9"),
|
||||
listOf("C", "0", "⌫"),
|
||||
)
|
||||
|
||||
buttons.forEach { row ->
|
||||
rows.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")
|
||||
}
|
||||
}
|
||||
}
|
||||
"C" -> onClear()
|
||||
"⌫" -> onBackspace()
|
||||
else -> onDigit(label[0])
|
||||
}
|
||||
},
|
||||
modifier = Modifier.size(72.dp),
|
||||
modifier = Modifier.size(76.dp),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
) {
|
||||
Text(label, fontSize = 24.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user