Closes out the Android terminal plan (Phases A → F).
- MainActivity:
* Immersive mode via WindowInsetsControllerCompat — hides status +
navigation bars, swipe-to-reveal with auto-hide
(BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE). Re-applied in onResume so
a transient overlay can't leave the bars visible.
* Lock Task Mode — startLockTask() in onResume. The manifest already
declares lockTaskMode="if_whitelisted", so this enters kiosk on a
properly MDM-provisioned tablet and is a silent no-op (caught
SecurityException) on dev / unprovisioned devices.
- TerminalScreen:
* SnackbarHost pinned at the bottom; LaunchedEffect(actionResult)
shows "Queued — will sync when back online" whenever an offline
action gets queued, so the cashier has explicit feedback beyond the
top-bar pending pill.
* 4 new locale strings (en/fr/de/lb) for the snackbar copy.
- Manifest cleanup: dropped redundant tools:replace from the debug
AndroidManifest overlay (networkSecurityConfig isn't set in the main
manifest, so the replace directive was a no-op + emitted a warning).
Skipped from the plan: a custom splash screen (the existing theme
background renders for the cold-start frame; adding the splashscreen
library is polish for a follow-up) and per-action success toasts
(the action sheet closing + balance refresh + recent-feed update are
adequate confirmation).
Verified by ./gradlew assembleDebug — clean build.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stamp / earn / enroll actions performed without connectivity now persist
in the existing pending_transactions Room table and drain via a
SyncWorker as soon as the network constraint is satisfied. Redemption
stays online-only (server needs the authoritative balance — queueing
would let cashiers redeem rewards customers have already spent).
Pieces:
- Hilt-Work plumbing: androidx.hilt:hilt-compiler KSP processor wired,
RewardFlowApp now implements Configuration.Provider with the injected
HiltWorkerFactory, AndroidManifest disables the WorkManager auto-init
via the AndroidX startup tools:node="remove" pattern. Bumped Dagger
to 2.55 so its compiler can read Kotlin 2.1 metadata — 2.51 crashed
with "Unable to read Kotlin metadata due to unsupported metadata
version" once we added @Inject lateinit var on the Application class.
- data/sync/SyncWorker.kt — @HiltWorker CoroutineWorker that drains the
queue FIFO. Per row: HTTP error → permanent markFailed; IOException
→ Result.retry(); success → markSynced + sweep at end of run.
- data/repository/QueueRepository.kt — single entrypoint for "queue
this offline action". queueStamp / queuePointsEarn / queueEnroll
Moshi-serialize the request body and enqueueUniqueWork(KEEP) the
worker under a NetworkType.CONNECTED constraint with 30s exponential
backoff. scheduleSync() is idempotent.
- TerminalViewModel: runAction(block, queueOnNetworkFailure) — on
IOException AND a queue lambda is provided, queue + emit
ActionResult.Queued. HttpException paths still surface the server's
message inline. Stamp / Earn / Enroll go through the queue path;
redeem actions pass null. setStaffPinId records who initiated each
queued row. init schedules a sync so anything left from a previous
session drains on screen entry. pendingSyncCount is combined into
state via Flow<Int> from the DAO; when it drops to 0 we refresh the
recent-transactions feed so the UI shows what just synced.
- TerminalScreen: PendingSyncPill in the top bar (only visible when
count > 0) gives the cashier feedback that a queued action is
waiting + that the queue is draining live.
Cleartext + readable HTTP errors from yesterday remain in.
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>