Two small dev-quality fixes shaken out by manual testing:
- Add a debug-only network_security_config.xml that whitelists 10.0.2.2,
localhost and 127.0.0.1 for cleartext HTTP. Without this, the dev
emulator can't reach the Python dev server because targetSdk 35
forbids cleartext HTTP by default. Lives under app/src/debug/ so it
ships only in debug APKs — release builds keep the platform default
(no cleartext at all).
- TerminalViewModel.runAction now extracts the JSON {message: ...} field
from HttpException response bodies instead of just showing "HTTP 400".
Cashiers (and developers) now see "Staff PIN is required for this
operation" / "Daily stamp limit of N reached" inline in the failed
action sheet, surfacing the same business-error text the server
already returns.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wraps the terminal screen in an IdleTracker that observes pointer-down
events and fires onLockScreen after 2 min of silence (per spec). Each
tap restarts the timer via a LaunchedEffect re-launch.
awaitFirstDown(requireUnconsumed = false) lets us observe touches
without intercepting them, so children (text fields, buttons, lists,
keypad) keep working normally. Body extracted into a private
TerminalContent so the wrapper stays tidy.
Caveat: Compose AlertDialogs render in a separate window, so the timer
keeps ticking through an open dialog. This is correct behavior — a
cashier who walks away mid-dialog should be locked out — and the
dialog disposes when the NavHost pops the terminal route on lock.
Documented in IdleTracker.kt.
Verified by ./gradlew assembleDebug — clean build.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both left-pane buttons now functional. Cashier can find a customer by
scanning their loyalty QR or enroll a brand-new customer from the
tablet — closing the last gap in the everyday POS flow.
- TerminalViewModel: scannerOpen / enrollDialogOpen / enrolling /
enrollError state. submitEnroll posts to /cards/enroll, then re-fetches
the lookup shape so the customer pane renders fully (rewards,
cooldown). On lookup-after-enroll failure (rare) the new card_number
is pre-filled in the search field as a fallback.
- EnrollDialog.kt (new): AlertDialog with name + email (required), phone
+ birthday (optional; birthday is plain YYYY-MM-DD text — date picker
is a polish task). Inline error surface for backend rejections.
- QrScannerOverlay.kt (new): fullscreen overlay reusing QrScannerView
from Phase B. Cancel button top-right. Decoded value is treated as a
card_number and feeds the lookup flow.
- TerminalScreen: scan/enroll buttons are no longer disabled; the two
new composables render conditionally on top of the main layout.
The tablet now supports every everyday flow: lookup, scan, enroll,
stamp, earn points, redeem stamps, redeem reward, recent feed.
Verified by ./gradlew assembleDebug — clean build.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Left pane below the search/scan/enroll buttons now shows the last ~10
transactions for the merchant, refreshed on init and after every
successful action sheet submission.
- ApiModels: TransactionListResponse + TransactionItem mirroring the
store-API shape (deltas, customer/staff names, purchase amount,
reward description, ISO transaction_at).
- LoyaltyApi.listRecentTransactions(skip, limit) → GET /transactions.
- TerminalViewModel.refreshRecentTransactions called on init and from
runAction's success path so balances + feed stay in lockstep.
- TerminalScreen: RecentTransactionsList composable. Each row renders
the type (translated by transactionLabel), customer name when known,
signed delta (+50 pts / +1 ★ / -100 pts), and HH:mm timestamp
localized to the device timezone via java.time (Android 26+).
Verified by ./gradlew assembleDebug — clean build.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The four right-pane buttons now work end-to-end (online happy path).
Each opens an AlertDialog wired to a ViewModel action; on success the
sheet closes and the customer card refreshes from the server.
- TerminalViewModel: + categories cache load (via CategoryRepository),
+ activeAction / actionInProgress / actionResult on the state. Action
methods (submitStamp, submitEarnPoints, submitRedeemStamps,
submitRedeemReward) all funnel through a runAction helper that toggles
in-progress, calls the LoyaltyApi, refreshes the customer on success,
surfaces failures inline.
- ActionSheets.kt (new): one ActionSheet entrypoint that dispatches by
ActionKind. Category multi-select via FlowRow + FilterChip pills.
EarnPoints accepts "12.50" / "12,50" → cents. Reward picker shows
available_rewards from the lookup response.
- TerminalScreen: action buttons enabled per state — stamp/earn stay
active offline (Phase E will queue them), redeem is hard-disabled when
offline per the plan ("redemption requires an internet connection").
Categories list comes from the cache filled at pairing; fresh refresh
runs in the background so newly-added categories show up next launch
without a manual sync.
Verified by ./gradlew assembleDebug — clean build.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The terminal screen is now functional for card lookup and customer
display. Phase D.2 will fill in the action sheets (stamp / earn /
redeem) on top of this.
- TerminalViewModel: state machine with program (from cache), customer,
search/error/online state. onSearchSubmit hits /cards/lookup;
refreshCurrentCustomer re-fetches after actions land.
- TerminalScreen rewrite: top bar with staff name + online pill + Lock;
left pane with search field + buttons; right pane shows the empty
state or a customer panel (name/email/card number, points + stamps
card, four placeholder action buttons greyed out for D.2).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>