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>
End-to-end pairing flow:
1. SetupScreen renders a CameraX preview on the left, a manual-entry
form on the right (dev fallback). Camera permission is requested
in-place — no accompanist dep.
2. QrScannerView uses ML Kit's barcode scanner (QR format only),
single-shot fires the decoded JSON to the ViewModel and stops
analysing.
3. SetupViewModel.pairFromQr decodes via Moshi, persists the pairing
in DataStore, then verifies by hitting /api/v1/store/loyalty/program
through the AuthInterceptor (which now sees the new url + token).
On 200 it warms the staff PIN and category caches and emits Success;
on failure it rolls back via DeviceConfigRepository.resetDevice() so
the user is back at a clean Setup with an error.
4. The NavHost watches is_device_set_up and forwards to PIN once Success
fires. The DataStore key was aligned to "is_device_set_up" so this
reactive switch keeps working.
Backend: the QR payload generated by POST /merchants/loyalty/devices
now includes store_id and store_name in addition to api_url, store_code
and auth_token, so the tablet doesn't have to resolve them later via a
separate call. Old QRs (which only had three fields) won't decode — the
merchant has to revoke and re-pair, which is the same flow they'd run
anyway after losing a tablet.
Files:
- ui/scanner/QrScannerView.kt (new) — CameraX + ML Kit composable
- ui/setup/SetupViewModel.kt (rewrite) — pair flow + state machine
- ui/setup/SetupScreen.kt (rewrite) — two-pane layout, status overlay
- data/model/ApiModels.kt — SetupPayload model
- data/repository/DeviceConfigRepository.kt — IS_SET_UP key alignment
- app/modules/loyalty/services/terminal_device_service.py — richer QR payload
Verified by ./gradlew assembleDebug — clean build, all warnings address
in this commit (LocalLifecycleOwner moved to lifecycle.compose, OptIn on
ExperimentalGetImage removed since it's no longer @RequiresOptIn).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Foundation work for the Android tablet POS app. Adds the singletons that
every screen needs (config persistence, network awareness, auth) without
touching the existing screen stubs yet — Phases B–F will build on these.
- gradle: bcrypt 0.10.2 pinned (used in Phase C for offline PIN verify)
- LoyaltyApi: + GET /api/v1/store/loyalty/categories endpoint
- ApiModels: + category_ids on StampRequest / PointsEarnRequest,
+ CategoryItem / CategoryListResponse
- DeviceConfigRepository: DataStore wrapper for the paired-tablet state
(api_url, auth_token, store_id/code/name, cached program/pins/categories
JSON, per-seller language, resetDevice())
- AuthInterceptor: rewrites every request's host to the paired api_url
and adds Bearer auth — Retrofit keeps a placeholder baseUrl since the
real URL only exists post-pair
- NetworkMonitor: Flow<Boolean> isOnline from ConnectivityManager
- StaffPinRepository / CategoryRepository: cache-or-refresh pattern,
Moshi-serialized to DataStore
- AppModule: wires AuthInterceptor before the logging interceptor
- strings.xml: ~50 strings × 4 locales (en authoritative; fr/de/lb
translated, mirroring the loyalty backend's i18n approach)
Verified by ./gradlew assembleDebug — clean build, only pre-existing
warnings (Theme statusBarColor deprecation, Moshi-kapt deprecation).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rename apps/ → clients/ for clarity:
- app/ (singular) = Python backend (FastAPI, server-rendered web UI)
- clients/ (plural) = standalone client applications (API consumers)
The web storefront/store/admin stays in app/ because it's server-
rendered Jinja2, not a standalone frontend. clients/ is for native
apps that connect to the API externally.
Updated:
- docs/architecture/overview.md — added clients/ to project structure
- clients/terminal-android/SETUP.md — updated path references
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>