Przejdź do treści

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.dartkAppVersion)
  • Flutter SDK: >=3.22.0 (Dart >=3.4.0 <4.0.0)
  • Production unit: LOCKER-SZB-SDI-001 on 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 DEPOSITED shipments 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:

  1. Pushes LockerOpenScreen for that locker.
  2. LockerOpenScreen calls LockerHardware.open(n), waits for the LockerEvent.opened, shows the open instruction; then waits for LockerEvent.closed and calls the supplied onClosed callback.
  3. onClosed is POST /pickup/:id/complete (online) or OfflineRepo.markPickedUpLocally + SyncService.flush() (offline fallback).
  4. 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:

  1. Sender path (employees + admins) — pick recipient (DepositRecipientScreen typeahead → /deposit/recipients) → pick size + function (DepositSizeScreen/deposit/begin) → reserve locker → LockerOpenScreen/shipments/:id/depositedDepositSuccessScreen.
  2. 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-existing reserves a free slot here → open it.
  3. 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); busyCount ValueNotifier reflects current busy depth.
  • InactivityNavigatorObserver — wired into MaterialApp.navigatorObservers. Calls bump() on every didPush / didPop / didReplace, so any screen transition resets the clock.
  • InactivityGuard — mounted just below the scope. Listens to controller events. Idle for inactivityTimeoutSec (default 5 s) → fade in a glass overlay with a inactivityCountdownSec (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). While busyCount > 0 the overlay is hidden and the countdown is frozen — so a user clicking "Pickup" and waiting for /pickup/by-user doesn'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:

  1. Pull GET /machines/me/sync?since=<lastCheckpoint> → upsert into sqflite (cached_users, cached_credentials, cached_lockers, cached_shipments) → persist new checkpoint.
  2. Push — drain outbox via POST /machines/me/replay. OK and CONFLICT rows are deleted; FAILED rows get retryCount++ 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(); }

  • MockLockerHardwareopen(n) → 300 ms delay → emits LockerEvent.opened → 8 s auto-close (interruptible by another open). devForceClose(n) is a hidden hot-corner for QA.
  • ModbusLockerHardware — etap 5. Real Modbus TCP driver, production for LOCKER-SZB-SDI-001 since v0.78. Pulses the lock coil to release, polls the contact sensor at ~5 Hz to detect close. LockerOpenScreen blocks Nadaj if the cell is still open ("nie wykrył otwartej skrytki" guard introduced in v0.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):

  1. GET /machines/me/release-assignment (machine-auth).
  2. If PENDING, write update-state.json { phase: "downloading" }, stream the binary from GET /releases/:id/file, hashing on the fly (256 KB buffer + 5 s status throttle on POST /machines/me/release-assignment/:id/status).
  3. Verify SHA-256 against release.sha256 — mismatch → write phase: "failed", report FAILED.
  4. Write phase: "installing", drop into C:\smartbox-kiosk\versions\<ver>\, atomically flip the current junction.
  5. Write phase: "restarting" before stopping the UI Scheduled Task (so the next-boot UI sees the splash immediately). Stop + Start SmartBoxKioskUI.
  6. New process boots, UpdateStateWatcher sees phase: "installed", splash lingers ~3 s, then fades back to login.

The Flutter UI's contribution:

  • UpdateStateWatcher polls C:\smartbox-kiosk\update-state.json every 1 s.
  • UpdateSplash renders a fullscreen glass card with a per-phase round icon, progress bar with MB counter, and vX.Y.Z pill.
  • InactivityGuard is 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 panelSystem → 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:

  • WindowsC:\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.00.48.0
4.x + polish drops 0.49.00.58.0
5.x kiosk hardware (Modbus + locker lifecycle hardening) 0.78.00.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/verify returns 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 statepauseWhile<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 LockerHardware events, 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.