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,
|
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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) }
|
|
||||||
|
|
||||||
|
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(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Column(
|
CircularProgressIndicator()
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
}
|
||||||
modifier = Modifier.padding(48.dp),
|
}
|
||||||
|
pins.isEmpty() -> {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Enter Staff PIN",
|
text = stringResource(R.string.pin_no_staff),
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
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
|
@Composable
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
private fun StaffRow(pin: PinForDeviceItem) {
|
||||||
repeat(4) { i ->
|
val containerColor = when {
|
||||||
|
pin.is_locked -> MaterialTheme.colorScheme.errorContainer
|
||||||
|
!pin.is_active -> MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
else -> MaterialTheme.colorScheme.surface
|
||||||
|
}
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.size(56.dp),
|
modifier = Modifier
|
||||||
shape = MaterialTheme.shapes.medium,
|
.fillMaxWidth()
|
||||||
color = if (pinDigits.length > i)
|
.clip(RoundedCornerShape(12.dp))
|
||||||
MaterialTheme.colorScheme.primary
|
.border(
|
||||||
else
|
width = 1.dp,
|
||||||
MaterialTheme.colorScheme.surfaceVariant,
|
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) {
|
Box(contentAlignment = Alignment.Center) {
|
||||||
if (pinDigits.length > i) {
|
if (filled) {
|
||||||
Text("•", fontSize = 32.sp, color = MaterialTheme.colorScheme.onPrimary)
|
Text(
|
||||||
|
text = "•",
|
||||||
|
fontSize = 28.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
error?.let {
|
if (state.errorMessage != null) {
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(Modifier.height(10.dp))
|
||||||
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
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
|
Spacer(Modifier.height(28.dp))
|
||||||
val buttons = listOf(
|
|
||||||
|
val rows = listOf(
|
||||||
listOf("1", "2", "3"),
|
listOf("1", "2", "3"),
|
||||||
listOf("4", "5", "6"),
|
listOf("4", "5", "6"),
|
||||||
listOf("7", "8", "9"),
|
listOf("7", "8", "9"),
|
||||||
listOf("C", "0", "⌫"),
|
listOf("C", "0", "⌫"),
|
||||||
)
|
)
|
||||||
|
rows.forEach { row ->
|
||||||
buttons.forEach { row ->
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
row.forEach { label ->
|
row.forEach { label ->
|
||||||
FilledTonalButton(
|
FilledTonalButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
when (label) {
|
when (label) {
|
||||||
"C" -> { pinDigits = ""; error = null }
|
"C" -> onClear()
|
||||||
"⌫" -> { if (pinDigits.isNotEmpty()) pinDigits = pinDigits.dropLast(1) }
|
"⌫" -> onBackspace()
|
||||||
else -> {
|
else -> onDigit(label[0])
|
||||||
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),
|
modifier = Modifier.size(76.dp),
|
||||||
|
contentPadding = PaddingValues(0.dp),
|
||||||
) {
|
) {
|
||||||
Text(label, fontSize = 24.sp)
|
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