SmartBox Kiosk (Flutter)¶
📖 Online: docs.smartbox.ergoflow.app/components/flutter-machine-ui/
Touch-screen UI for the physical paczkomat. Production runs on Windows desktop under the kiosk user's session via SmartBoxKioskUI Scheduled Task + Winlogon auto-logon. Linux desktop is the secondary target (dev + single-machine pilots). Android is a tertiary target — same code, hasn't been deployed yet.
- Current version:
0.83.0(lib/core/version.dart→kAppVersion) - Flutter SDK:
>=3.22.0(Dart>=3.4.0 <4.0.0) - Production unit:
LOCKER-SZB-SDI-001on TEMREXLOCKERBOX (172.31.0.201) - Auth: machine API key only — never holds user JWTs. Identity is resolved server-side and returned inline.
Layout¶
flutter-machine-ui/
├── pubspec.yaml deps + version + flutter SDK constraint
├── l10n.yaml ARB → AppLocalizations codegen config
├── assets/ hero photo, brand bubbles, logos
└── lib/
├── main.dart sqflite_ffi init, window_manager fullscreen, MachineConfig.load(), runApp
├── app.dart SmartboxKioskApp — wires UpdateStateWatcher, SyncService,
│ InactivityScope, InactivityNavigatorObserver, InactivityGuard, routes
├── theme.dart KioskTheme.dark — ink-950 base, cyan accent, leaf-green positive
├── core/
│ ├── api_client.dart HTTP wrapper — sets X-Machine-Api-Key, JSON helpers, error mapping
│ ├── machine_config.dart reads C:\smartbox-kiosk\config.json or ~/.smartbox-kiosk/config.json
│ ├── machine_info.dart live machine snapshot (GET /machines/me every 30 s)
│ └── version.dart kAppVersion = '0.83.0'
├── i18n/
│ ├── app_pl.arb Polish strings (source)
│ ├── app_en.arb English mirror
│ └── app_localizations.dart generated by `flutter gen-l10n`
├── hardware/
│ ├── locker_hardware.dart abstract LockerHardware — open() / events / isClosed() / dispose()
│ ├── mock_locker_hardware.dart etap 4.3 — timer-based mock (300 ms open, 8 s auto-close)
│ └── modbus_locker_hardware.dart etap 5 — Modbus TCP driver wired in production
│ (LOCKER-SZB-SDI-001 has been running on this since v0.78)
├── offline/
│ ├── local_db.dart sqflite_common_ffi singleton — schema:
│ │ cached_users, cached_credentials, cached_lockers,
│ │ cached_shipments, outbox, sync_cursor
│ ├── offline_repo.dart authenticatePin(), shipmentsForUser(), markPickedUpLocally()
│ └── sync_service.dart 2-min tick — pull /machines/me/sync + flush outbox via /machines/me/replay
├── widgets/
│ ├── header_bar.dart top bar (machine name + location + sync chip + profile avatar)
│ ├── footer_brand.dart ErgoFlow + SmartBox branding
│ ├── dashboard_background.dart dark gradient + drifting brand bubbles
│ ├── info_banner.dart inline status / error / success
│ ├── pin_pad.dart on-screen 3×4 keypad with submit dot indicator
│ ├── smartbox_logo.dart SVG wordmark
│ ├── ergoflow_logo.dart ErgoFlow wordmark for footer
│ ├── language_flag.dart circular flag picker (PL + EN, active flag glows)
│ ├── inactivity_scope.dart InactivityController + InheritedWidget +
│ │ InactivityNavigatorObserver — coordinates idle timing
│ ├── inactivity_guard.dart countdown overlay; pauses while busy
│ ├── sync_status_banner.dart "OFFLINE (n queued)" / "SYNC (n queued)" header chip
│ └── update_splash.dart OTA progress overlay
├── screens/
│ ├── login_screen.dart RFID-first + PIN tabs; submits to /pickup/verify; falls back to offline
│ ├── user_menu_screen.dart post-login: Pickup / Deposit tiles (gated by availableFunctions)
│ ├── profile_screen.dart edit phone + preferred language (PATCH /kiosk/users/:userId/profile)
│ ├── pickup_screen.dart list DEPOSITED shipments → LockerOpenScreen → /pickup/:id/complete
│ ├── deposit_choice_screen.dart courier branch — existing-by-suffix vs. new shipment
│ ├── deposit_lookup_screen.dart courier — last-6 tracking lookup (/deposit/lookup) + size sheet
│ ├── deposit_recipient_screen.dart recipient typeahead (/deposit/recipients)
│ ├── deposit_size_screen.dart size + function chips → /deposit/begin
│ ├── deposit_success_screen.dart thank-you summary (locker # + tracking + recipient + QR)
│ ├── locker_open_screen.dart "open / take / close" dance, listens to LockerHardware events
│ └── no_parcels_screen.dart fallback for empty pickup list
└── updater/
├── update_state.dart UpdatePhase enum + UpdateSnapshot value object
└── update_state_watcher.dart 1 s poll of C:\smartbox-kiosk\update-state.json
Screen flows¶
Login¶
LoginScreen has two tabs — RFID (placeholder reader stub, the kiosk listens for card UID strings on a serial bridge in production; default tab) and PIN (custom on-screen keypad, 6-digit submit). Either path posts to POST /pickup/verify (machine-auth, returns the matched user + their pending shipments). On network failure the app falls back to OfflineRepo.authenticatePin against the cached bcrypt hashes (sync'd by SyncService).
The header carries a 2-flag picker (PL / EN) and shows the live machine name + location from GET /machines/me.
Post-login menu¶
UserMenuScreen shows up to two tiles:
- Odbierz — pickup flow. Visible if the user has any
DEPOSITEDshipments waiting at this machine (or the function is enabled). - Nadaj — deposit flow. Visible based on role + machine
availableFunctions. EMPLOYEE users see pickup only. ADMIN + COURIER see both.
The header on this screen and beyond carries an avatar / profile link that opens ProfileScreen.
Pickup¶
PickupScreen lists the user's DEPOSITED shipments at this machine. Each card has a Picked up button which:
- Pushes
LockerOpenScreenfor that locker. LockerOpenScreencallsLockerHardware.open(n), waits for theLockerEvent.opened, shows the open instruction; then waits forLockerEvent.closedand calls the suppliedonClosedcallback.onClosedisPOST /pickup/:id/complete(online) orOfflineRepo.markPickedUpLocally + SyncService.flush()(offline fallback).- Card flips to "Picked up" and after the last one the screen auto-bounces back to the login screen.
Deposit¶
Three branches, all post-login:
- Sender path (employees + admins) — pick recipient (
DepositRecipientScreentypeahead →/deposit/recipients) → pick size + function (DepositSizeScreen→/deposit/begin) → reserve locker →LockerOpenScreen→/shipments/:id/deposited→DepositSuccessScreen. - Courier-with-tracking-ID path — courier types the last 6 digits of an existing CREATED/ASSIGNED tracking ID (
DepositLookupScreen→/deposit/lookup). If shipment is already ASSIGNED to this machine with a locker, jump straight to opening it. Otherwise prompt for size (_SizeSheet) →/deposit/begin-existingreserves a free slot here → open it. - Profile screen — edit phone + preferred locale (PATCH
/kiosk/users/:userId/profile). Name and email are read-only.
Inactivity model¶
Owned by InactivityScope (an InheritedWidget) at the app root. Three collaborators:
InactivityController— single source of truth.bump()resets the idle clock;pauseWhile<T>(Future<T>)is reference-counted (multiple in-flight network ops can overlap, the timer only resumes when all complete);busyCountValueNotifier reflects current busy depth.InactivityNavigatorObserver— wired intoMaterialApp.navigatorObservers. Callsbump()on everydidPush/didPop/didReplace, so any screen transition resets the clock.InactivityGuard— mounted just below the scope. Listens to controller events. Idle forinactivityTimeoutSec(default 5 s) → fade in a glass overlay with ainactivityCountdownSec(default 10 s) cyan→leaf gradient countdown digit + "Czy jesteś tam jeszcze?" copy. Any touch dismisses the overlay; running out bounces back to the login screen (or soft-resets if already there). WhilebusyCount > 0the overlay is hidden and the countdown is frozen — so a user clicking "Pickup" and waiting for/pickup/by-userdoesn't get logged out mid-fetch.
Both timeouts are per-machine, edited from the panel's Edit-machine dialog, surfaced via GET /machines/me every 30 s.
Hot paths that explicitly wrap their network calls in scope.pauseWhile(...) (because the auto-bump on navigation alone isn't enough):
- Login PIN verify
- User menu → pickup fetch (online + offline)
- Deposit recipient typeahead
- Deposit size confirm (
/deposit/begin) - Deposit lookup + begin-existing
- Profile save
- Pickup complete
Offline mode (etap 4.5)¶
SyncService ticks every 2 minutes (configurable). On every tick:
- Pull
GET /machines/me/sync?since=<lastCheckpoint>→ upsert into sqflite (cached_users,cached_credentials,cached_lockers,cached_shipments) → persist new checkpoint. - Push — drain
outboxviaPOST /machines/me/replay. OK and CONFLICT rows are deleted; FAILED rows getretryCount++and are retried next tick.
The header chip shows OFFLINE (n queued) in amber when the link is degraded, and a transient SYNC (n queued) in cyan while flushing. Hides itself entirely when online + outbox empty.
| Operation | Online | Offline |
|---|---|---|
| PIN auth | POST /pickup/verify |
bcrypt-compare against cached hashes |
| Pickup — list parcels | GET /pickup/by-user (creates OPEN_LOCKER tasks server-side) |
OfflineRepo.shipmentsForUser against local cache |
| Pickup — door open | LockerHardware.open() |
identical (hardware is local) |
| Pickup — complete | POST /pickup/:id/complete |
enqueue pickup.completed in outbox; flush on next tick |
| Deposit (sender) | full online flow | NOT supported offline — needs backend to allocate locker + tracking ID |
Conflict resolution is server-wins: if the panel cancelled a shipment while the kiosk was offline, the replay returns outcome: 'CONFLICT' and the kiosk drops the row from the outbox.
See ../docs/etap-4-offline-guarantees.md for the full conflict matrix.
ErgoFlow employees (etap 6) flow through this same cache: the backend's ErgoFlow sync JIT-creates a SmartBox User + RFID Credential (valueHash = bcrypt(userNumber)) for every active upstream employee, the next /machines/me/sync tick fans that row out to cached_users + cached_credentials, and offline RFID auth at the kiosk works without a network round-trip.
Hardware abstraction (etap 4.3)¶
dart
abstract class LockerHardware {
Future<void> open(int lockerNumber);
Stream<LockerEvent> get events; // opened / closed
Future<bool> isClosed(int lockerNumber);
Future<void> dispose();
}
MockLockerHardware—open(n)→ 300 ms delay → emitsLockerEvent.opened→ 8 s auto-close (interruptible by anotheropen).devForceClose(n)is a hidden hot-corner for QA.ModbusLockerHardware— etap 5. Real Modbus TCP driver, production forLOCKER-SZB-SDI-001sincev0.78. Pulses the lock coil to release, polls the contact sensor at ~5 Hz to detect close.LockerOpenScreenblocksNadajif the cell is still open ("nie wykrył otwartej skrytki" guard introduced inv0.83), and clears the PIN buffer on auth failure.
OTA model¶
The Windows PowerShell agent (infra/scripts/windows-kiosk/agent/smartbox-kiosk-agent.ps1, runs as LocalSystem under the SmartBoxKioskAgent Scheduled Task) is the install authority. The Flutter app does not download or install updates — it just renders the splash.
Agent loop (every 30 s):
GET /machines/me/release-assignment(machine-auth).- If
PENDING, writeupdate-state.json { phase: "downloading" }, stream the binary fromGET /releases/:id/file, hashing on the fly (256 KB buffer + 5 s status throttle onPOST /machines/me/release-assignment/:id/status). - Verify SHA-256 against
release.sha256— mismatch → writephase: "failed", reportFAILED. - Write
phase: "installing", drop intoC:\smartbox-kiosk\versions\<ver>\, atomically flip thecurrentjunction. - Write
phase: "restarting"before stopping the UI Scheduled Task (so the next-boot UI sees the splash immediately). Stop + StartSmartBoxKioskUI. - New process boots,
UpdateStateWatcherseesphase: "installed", splash lingers ~3 s, then fades back to login.
The Flutter UI's contribution:
UpdateStateWatcherpollsC:\smartbox-kiosk\update-state.jsonevery 1 s.UpdateSplashrenders a fullscreen glass card with a per-phase round icon, progress bar with MB counter, andvX.Y.Zpill.InactivityGuardis paused while OTA is active — the user is never logged out mid-update.
End-to-end on a fresh ~12 MB release: ~3 s download + ~10 s install + ~3 s "installed" linger.
Pushing an OTA update¶
Two paths, same end state:
Via the panel — System → Wersje aplikacji → Wgraj (multipart upload, panel computes SHA-256, drops the file into the smartbox_release_files Docker volume). Then on the machine's detail page → Aktualizuj → pick version. That's it — within 30 s the agent picks it up and you can watch progress on the same page (ReleaseStatusCard polls every 4 s).
Via shell on the VM — handy for repeatable scripts. See the recipe in the project root README.md → "Pushing an OTA update" for the SQL inserts.
Configuration¶
A single JSON file holds the per-machine credentials. Path varies per OS:
- Windows —
C:\smartbox-kiosk\config.json(preferred) or%APPDATA%\app.smartbox\config.json - Linux —
~/.smartbox-kiosk/config.json(preferred) or$XDG_DATA_HOME/app.smartbox/config.json
json
{
"apiBase": "https://api.smartbox.ergoflow.app",
"machineId": "<uuid from POST /machines>",
"machineApiKey": "<plain key shown ONCE on registration>",
"displayName": "<machine code> / <location> / <ip>"
}
For dev without a config file, the app reads the same values from env vars: SMARTBOX_API_BASE, SMARTBOX_MACHINE_ID, SMARTBOX_MACHINE_API_KEY, SMARTBOX_DISPLAY_NAME.
The config file is persistent across OTA flips — it lives outside the version directory.
Dependencies¶
Key entries from pubspec.yaml:
| Package | Purpose |
|---|---|
flutter_localizations |
i18n SDK |
intl ^0.19 |
translation keys + formatting |
http ^1.2 |
HTTP client (kiosk → backend) |
crypto ^3.0 |
hashing helpers |
path ^1.9 + path_provider ^2.1 |
OS-aware paths |
package_info_plus ^8.0 |
app version metadata |
shared_preferences ^2.3 |
minimal persistent KV (mostly i18n prefs) |
flutter_svg ^2.0 |
logo + bubble SVGs |
window_manager ^0.4 |
desktop fullscreen / always-on-top / borderless |
sqflite_common_ffi ^2.3 |
offline cache (FFI-backed SQLite, no platform plugin) |
bcrypt ^1.1 |
offline PIN hash compare |
Dev deps: flutter_test, flutter_lints ^4.0.
Build¶
```bash
Linux desktop¶
flutter pub get flutter gen-l10n # regenerates lib/i18n/app_localizations.dart from .arb flutter build linux --release
Output: build/linux/x64/release/bundle/¶
Windows desktop¶
flutter pub get flutter gen-l10n flutter build windows --release
Output: build/windows/x64/runner/Release/¶
Run in dev¶
flutter run -d windows # or -d linux / -d
If you've never built on this machine, you also need the platform scaffolding:
bash
flutter create --platforms=windows,linux --org app.smartbox --project-name smartbox_kiosk .
(The linux/ and windows/ folders are gitignored — they're regenerated per build host.)
First-time setup¶
For the production Windows kiosk path see the project root README.md → "First-time UI start on a freshly bootstrapped kiosk". The bootstrap script infra/scripts/windows-kiosk/bootstrap-all.ps1 brings a bare Windows box from zero to a working kiosk in ~20 minutes.
For Linux dev kiosks see the same README's Linux section — single-machine pilots use a systemd user unit (~/.config/systemd/user/smartbox-kiosk-ui.service) instead of a Scheduled Task.
Versioning¶
lib/core/version.dart exports kAppVersion — currently '0.58.0'. Bump on every kiosk-visible chunk, in lockstep with pubspec.yaml's version: field (X.Y.Z+build). The OTA agent reports kAppVersion to the backend after a successful install (POST /machines/me/release-assignment/:id/status, installedVersion field) so Machine.currentVersion stays in sync.
| Etap | kAppVersion |
|---|---|
| 3.0 | 0.30.0 |
| 3.1 | 0.31.0 |
| 3.2 | 0.32.0 |
| 3.3 | 0.33.0 |
| 3.4 | 0.34.0 |
| 3.5 + 12 polish drops | 0.35.0 … 0.48.0 |
| 4.x + polish drops | 0.49.0 … 0.58.0 |
| 5.x kiosk hardware (Modbus + locker lifecycle hardening) | 0.78.0 … 0.83.0 |
Architecture notes¶
- Machine auth only — the kiosk never holds user JWTs. Every request carries
X-Machine-Api-Key. User identity is matched server-side and returned inline (e.g./pickup/verifyreturns the user + pending shipments). - Single inactivity guard at the app root prevents the race conditions we used to hit with per-screen guards firing simultaneously. All routes, network ops and manual interactions feed the one controller.
- Reference-counted busy state —
pauseWhile<T>()lets parallel network ops overlap. The timer only resumes when all in-flight ops complete. - Server is the authority for locker state. The kiosk reads
LockerHardwareevents, opens / waits for close, then POSTs the completion. There's no client-side state machine for locker lifecycle. - Window manager keeps the app fullscreen / always-on-top / borderless on desktop, with the taskbar hidden. Android already runs fullscreen.