Guest app
The guest app is the primary activation channel. It is a single home screen: sign in, see the membership card and points balance, open a check-in pass for the gate, jump to booking, and play with the engagement partner’s widgets. Rewards, raffles and games are the partner’s widgets, not app screens, so they can change without an app release.
Repository: app.
Tech stack
Section titled “Tech stack”- Framework
- Flutter 3.47Dart 3.13
- Targets
- iOSAndroidWeb (Cloudflare Workers, static)
- State & navigation
- ChangeNotifierListenableBuilderNavigator 1.0No state-management or router package; dependencies wired by hand in main_prod.dart.
- Sign-in
- oidc 5oidc_default_storePKCEHardened secure storage on mobile (Keychain / Keystore).
- Networking
- http
- Partner widgets
- webview_flutter (mobile)sandboxed iframe (web)
- Other
- pretty_qr_codeurl_launcherpackage_info_plus
- Look
- ManropeInstrument Seriflight theme: aqua on ink, on sand
- Build
- flutter_flavorizr (prod, test)GitHub Actions → Cloudflare (web)
Structure
Section titled “Structure”flowchart TB
classDef ours fill:#13686C,stroke:#0B4A4D,color:#ffffff
classDef area fill:#E3F4F3,stroke:#13686C,color:#12191B
classDef ext fill:#F3ECDF,stroke:#B9A88B,color:#12191B
home["<b>HomeScreen</b><br/>membership card · Check in · Book · widget slot"]:::ours
subgraph features["Feature modules (lib/)"]
direction LR
auth["auth/<br/>CustomerAuth<br/>OIDC session"]:::area
corec["core/<br/>CoreClient<br/>401 → refresh → retry once"]:::area
points["points/<br/>PointsBalanceController<br/>polls every 3 s"]:::area
checkin["check_in/<br/>QrScreen<br/>rotating one-time QR"]:::area
book["booking/<br/>BookingLink<br/>handoff code → browser"]:::area
widgets["partner_widgets/<br/>launcher · slot · protocol v1"]:::area
end
home --> auth & points & checkin & book & widgets
points & checkin & book & widgets --> corec
auth --> kc["Keycloak"]:::ext
corec --> core["Core"]:::ours
widgets --> sota["Sota Platforms<br/>launch API + widget page"]:::ext
book --> site["Booking website"]:::ext
| Module | Calls | What it does |
|---|---|---|
auth/ |
Keycloak | Sign-in with PKCE, token refresh, offline session on mobile, sign-out |
core/ |
Core /api/v1/app/* |
Adds the access token; on 401 refreshes once and retries |
points/ |
GET /points |
Polls the balance every 3 s in the foreground; animates a “+10” when it changes |
check_in/ |
POST /qrs |
Shows a QR that rotates before its 60 s lifetime ends |
booking/ |
POST /handoffs |
Gets a one-time code and opens the booking site in the browser |
partner_widgets/ |
POST /widget-launches, partner’s /api/v1/launch |
Launches, renews and hosts the partner’s widgets |
Screens
Section titled “Screens”| Guest (signed out) | Member (signed in) |
|---|---|
| Welcome card with Sign in | Greeting and membership card: name, email, points balance |
| Book tile (plain booking site) | Check in and Book tiles |
| Partner widgets, guest mode (“sign in to start earning”) | Partner widgets, signed in |
The layout switches to two columns from 880 px, so the same build works on a phone and a desktop browser.
Hosting partner widgets
Section titled “Hosting partner widgets”The widget host is the most involved part of the app. It is described for partners in the Customer engagement section; internally, the key behaviours are:
stateDiagram-v2 direction LR [*] --> Launching: identity known Launching --> Loading: launch OK Launching --> Collapsed: launch failed Loading --> Live: page ready Loading --> Collapsed: not ready in 5 s Live --> Renewing: expiry near or token-expired Renewing --> Live: new token sent Renewing --> Collapsed: expired Live --> Paused: covered or backgrounded Paused --> Live: back on top Collapsed --> Launching: retry with back-off Live --> [*]: identity changes
- Back-off after failures: 5 s, 15 s, 60 s, then every 5 minutes.
- One launcher per identity (
guestorcustomer:<sub>). Signing in or out discards the page and clears the web view’s storage. - The token travels as a message, never in a URL: the page says
hello, the app answersinit {token, …}. - Renewal happens at
expiresIn − min(30 s, lifetime/4), only while the slot is visible and the app is in the foreground. - Points stay consistent without sharing data: after a widget posts points it sends
changed, and the app re-reads its own balance from core (and vice versa). - The slot survives the check-in screen being pushed on top; a covered page reporting height 0 is ignored rather than collapsing the slot.
Sign-in specifics
Section titled “Sign-in specifics”| Mobile (iOS, Android) | Web | |
|---|---|---|
| Browser | Ephemeral system browser session (e.g. ASWebAuthenticationSession on iOS) |
Redirect back through redirect.html |
| Prompt | prompt=login: password each sign-in |
Keycloak SSO session (30 min idle, 10 h max) |
| Staying signed in | Offline refresh token (~30 days idle), works offline at start-up | Keycloak session |
| Token storage | Keychain / Keystore (hardened) | Browser storage |
| Sign-out | Core /logout, then revoke refresh and access tokens |
Core /logout, then end the Keycloak session |
Configuration
Section titled “Configuration”Environments are chosen at build time with --dart-define: FINNS_CORE_URL, FINNS_AUTH_ISSUER, FINNS_BOOKING_URL, FINNS_WIDGET_URL. The prod and test flavors only change the app’s name and bundle id, so both can be installed side by side.
Security notes
Section titled “Security notes”- The app never sends its own token to a partner; it hands over one-time codes from core.
- The widget web view is locked to the partner’s origin, and messages are exchanged only with that exact origin.
- Android backups are off (restored encrypted tokens couldn’t be decrypted anyway). Cleartext HTTP is allowed only in debug builds.
Internal and confidential. Share partner sections only as partner builds (pnpm build:partner).

