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>