Appearance
Theseus start screen and tools
Added: 11 October 2026
After Wantok unlock, Theseus opens on a tool launcher: a sign-in card for the site, then one card per tool. Tools come from a registry, so a new tool is one registry entry plus its screen(s).
This page covers what the start screen shows, the sign-in model, the tool registry, and how to add a tool. It adds no server route and no new credential: sign-in is the site-pairing QR.
What the screen shows
| Part | Content |
|---|---|
| App bar | Theseus. Menu (⋮) with Lock Theseus (back to the staff passphrase). |
| Sign-in card | Signed out: Scan site QR to sign in. Signed in: the site (its host), the session state, Switch site, Sign out. Compact while the session is ok; a session that is not ok gets a banner that says what happened and offers the one action that fixes it. |
| Tools | One card per registered tool: icon, title, one-line description, and a live status line / badge when the tool has one. |
| Ops banner | A new ops event, a long outage of the ops stream and its end show as a banner under the app bar of whatever screen is open, also inside a tool. See ops inbox. |
Layout rules:
- Phone portrait: the sign-in card, then the tools in two columns. Cards in a row share one height and grow with their text.
- Very large text (two cards would be narrower than about 136 px times the text scale): one column, icon beside the text.
- Wide screens: up to four columns. Landscape: the sign-in card sits beside the tools.
- The page scrolls. Nothing is cut off on a small phone (360 x 640).
- Colours come from the theme's colour scheme only (light and dark).
- The site is shown by its host (
customer.example.test): every pairing is HTTPS. A long host wraps between its labels. The sign-out dialog and screen readers get the whole origin. - A tap on a disabled tool card opens nothing: it shows one sentence that says what to do (
Site map needs a signed-in site. Sign in first: scan the site QR.) and scrolls the sign-in card back into view.
Sign-in model
There is one credential per site: the th_ field token from the site-pairing QR, stored on the phone with the HTTPS origin. The start screen presents that pairing as a session:
| State | When | Sign-in card | Tools that need a site |
|---|---|---|---|
| Signed out | No pairing stored | Scan site QR to sign in | Disabled: Sign in first |
| Checking | GET /api/v1/theseus/session in flight | Checking session… | Disabled: Checking… |
| Ok | Probe answered 200 | Signed in to <host>, Session ok, check-again button | Enabled |
| Revoked | Probe answered 401 | Session revoked, filled Scan a new site QR (checking again cannot help; a check-again icon stays) | Disabled: Session revoked |
| Unreachable | No answer: no network, DNS, timeout | Site unreachable, check the network, Retry | Disabled: Site unreachable |
| Failed | The site answered with another status (404 from an older server, 500) | Session check failed, the HTTP status, "not a network problem on the phone", Retry | Disabled: Site error |
The card says Signed in to only while the session is ok; otherwise the caption is Site.
- Sign in: scan a site QR. The pairing is saved, then probed.
- Switch site: scan another site QR. It replaces the stored pairing and is probed. Backing out of the scanner keeps the current site.
- Sign out: after a confirmation, Theseus forgets the pairing on this phone. It does not revoke the field token on the server; the dialog says so and shows the token id. It also says that nothing on the site stops: a running recording continues. Revoking is a site-admin action:
DELETE /api/v1/theseus/qr/token/:token_id. Mobile Access offers Revoke last token only for the token it just minted. - Lock Theseus is a different thing: it returns to the staff passphrase and keeps the site pairing. Signing out of a site does not lock the app.
The session is probed when the start screen opens, after a scan, and on retry. A slow answer for an older pairing is ignored.
A tool that gets a 401 from the site while it is open reports it (context.reportSessionRejected()). The start screen then shows Session revoked and locks the site tools without another request.
Tools that do not need a site (the ops inbox) work in every state.
Tool registry
Code: theseus/lib/tools/.
| File | Content |
|---|---|
tool.dart | The API: TheseusTool, TheseusToolContext, TheseusToolServices, TheseusToolCardStatus. It imports no tool: a new tool never edits it |
tool_registry.dart | theseusToolRegistry: the tools on the start screen, in display order |
<name>_tool.dart | One file per tool: its registry entry |
<name>/ | The screens, client and models of a tool with more than one or two files (rssi_recorder/) |
A tool declares:
| Field | Meaning |
|---|---|
id | Stable id, lower-case words joined by - (rssi-recorder). Unique. The card key is theseus-tool-<id> |
title | Card title |
description | One line saying what the tool is for |
icon | Material icon |
requiresSite | true when the tool talks to the signed-in site. The card is disabled, with the reason, until the session is ok |
status | Optional: returns a TheseusToolCardStatus (label, tone, optional detail line, optional badge; badgeQuiet draws the badge in neutral colours, for a plain total) |
statusListenable | Optional: when it notifies, the card reads status again |
createState | Optional: creates the tool's own state (a ChangeNotifier): what must outlive its screens, such as a shared client or what the card shows. The app creates it once per unlock and disposes it when Theseus locks. The tool reads it with services.state<T>('<id>') |
onSiteReady | Optional: called once each time the site session becomes ok (start screen opened signed in, QR scanned, session checked again). The one moment a site tool may ask the site what its card shows: at most one request, no polling |
onSiteChanged | Optional: called when another site QR was scanned (with the new pairing, before its session is checked) or the user signed out (with null). The tool forgets what it knew about the previous site. No request from here |
open | Opens the tool. Receives a TheseusToolContext |
TheseusToolContext gives a tool what it needs when it opens:
site/pairing: the signed-in site (origin +th_field token).siteis never missing for a tool withrequiresSite: true.services: the shared clients (sessionClient,fieldClient,bleScanner), the app-level ops inbox controller (ops), the RTLS test QR store and platform hooks (rtlsStore,rtlsDeps, also used by tester mode before unlock), andstate<T>('<id>'): the tool's own state for this unlock.navigatorandpush(screen): open the tool's screen.reportSessionRejected(): tell the start screen that the site answered 401. The tool still has to say so on its own screen and send the user back.
Current tools:
| Id | Title | Needs a site | Card status |
|---|---|---|---|
ops-inbox | Ops inbox | No | Not configured / Connecting… / Live / Reconnecting… / Refused; the badge is 3 new (prominent) for events not seen yet, otherwise the plain number of events (quiet); newest event title. See ops inbox |
site-map | Site map | Yes | Ready. Opens the map picker |
rssi-recorder | RSSI recorder | Yes | Ready, or Recording… with the badge REC, the number of watches and the start time while a recording is known to run on the site. See RSSI recorder |
Adding a tool
- Write the screen(s) under
theseus/lib/screens/, or undertheseus/lib/tools/<name>/when the tool has its own client and models. - Add
theseus/lib/tools/<name>_tool.dartwith the entry. - Add the entry to
theseusToolRegistryintool_registry.dart.
dart
// theseus/lib/tools/gateway_check_tool.dart
import 'package:flutter/material.dart';
import '../screens/gateway_check_screen.dart';
import 'tool.dart';
final theseusGatewayCheckTool = TheseusTool(
id: 'gateway-check',
title: 'Gateway check',
description: 'See which gateways report',
icon: Icons.router_outlined,
requiresSite: true,
open: (context) =>
context.push<void>(TheseusGatewayCheckScreen(pairing: context.site)),
);dart
// theseus/lib/tools/tool_registry.dart
final List<TheseusTool> theseusToolRegistry = List.unmodifiable([
theseusOpsInboxTool,
theseusSiteMapTool,
theseusRssiRecorderTool,
theseusGatewayCheckTool,
]);The start screen now shows the card, disables it until the session is ok, and opens the screen. No other file changes, and no existing test has to change: test/tool_registry_test.dart checks every registered tool (unique, well-formed id; card text; one-line description of at most 48 characters) and pins only the ids and requiresSite of the first three tools. Run dart format on the new files.
A tool with its own state
A tool that must remember something while its screens are closed (a shared client, a live badge) declares createState. The app creates the object at unlock and disposes it at lock, so nothing leaks into the next unlock, and nothing has to be added to tool.dart or main.dart.
dart
// theseus/lib/tools/gateway_check_tool.dart
const kTheseusGatewayCheckToolId = 'gateway-check';
/// What the gateway check keeps for this unlock.
class TheseusGatewayCheckState extends ChangeNotifier {
int offline = 0;
void siteChanged() {
offline = 0;
notifyListeners();
}
}
TheseusGatewayCheckState theseusGatewayCheckOf(TheseusToolServices services) {
return services.state<TheseusGatewayCheckState>(kTheseusGatewayCheckToolId);
}
final theseusGatewayCheckTool = TheseusTool(
id: kTheseusGatewayCheckToolId,
title: 'Gateway check',
description: 'See which gateways report',
icon: Icons.router_outlined,
requiresSite: true,
createState: (_) => TheseusGatewayCheckState(),
statusListenable: theseusGatewayCheckOf,
status: (services) {
final offline = theseusGatewayCheckOf(services).offline;
if (offline == 0) return null; // the card shows 'Ready'
return TheseusToolCardStatus(
label: 'Gateways offline',
tone: TheseusToolStatusTone.attention,
badge: '$offline',
);
},
onSiteChanged: (services, _) => theseusGatewayCheckOf(services).siteChanged(),
open: (context) =>
context.push<void>(TheseusGatewayCheckScreen(pairing: context.site)),
);A test replaces the state of one tool without touching the others: TheseusApp(toolStates: {'gateway-check': (_) => FakeState()}), or buildTheseusApp(toolStates: …) in test/support/test_app.dart.
Notes:
- A tool that calls the site uses
Authorization: Bearer th_…fromcontext.site, like field APIs. Never the opstk_. - The start screen does not check the session again when a tool closes. A tool that gets a 401 says so on its own screen, calls
context.reportSessionRejected()and sends the user back. A 401 for a site the user has switched away from meanwhile is ignored. - The start screen never polls for a tool. A card that shows something from the site loads it in
onSiteReady(one request) and is kept up to date by the tool's own screens. - State that must outlive a screen goes in
createState, never in a top-level variable: a top-level value survives Lock Theseus. test/tool_registry_test.dartfails on a duplicate or malformed id, on empty card text, and whentool.dartstarts importing a tool.
Not built yet: grouping the cards into sections. With many tools the launcher is still one grid in registry order.
Tests and screenshots
theseus/test/start_screen_test.dart covers the cards in every session state, sign-in through the scanner route (a fake scanner is injected with TheseusApp(scannerBuilder: …)), switch site, sign out, lock, the hint of a blocked card, a tool's own state and the layouts. theseus/test/tool_registry_test.dart covers the registry and the tool API; theseus/test/ops_toast_test.dart the ops banner.
A test-only renderer writes PNG files of the real screens (fake dependencies, Roboto and Material Icons loaded from the Flutter SDK cache):
bash
cd theseus
THESEUS_SCREENSHOT_DIR=/tmp/theseus-screens flutter test test/screenshotsWithout THESEUS_SCREENSHOT_DIR (CI, plain flutter test) the same scenes run as layout smoke tests and write nothing. Scenes are in test/screenshots/screenshots_test.dart; add one per new screen.
Non-goals
Per-person accounts, passwords, OIDC, or any login that is not the site QR. Server-side sign-out (token revoke from the phone). More than one signed-in site at a time. Push delivery for tools while Theseus is closed.