Mobile & screens
Display screens (Okta Screen)
A phone is opened by a person; a screen is opened by nobody. A television on the classroom wall, a panel in the school lobby, a display by the complex's gate — a machine that runs all day with no user in front of it, driven by a separate app called Okta Screen (Android TV / Google TV, and Windows). It signs in by pairing only: no account and no password, just a code the organisation's administrator mints from the device registry and types on the screen once.
What sets a screen apart from a phone is where it stands, not who holds it: at pairing the administrator picks its place — the whole organisation (a lobby, a corridor), particular grades, or particular classrooms. Your app declares which places it serves, and the platform offers it to the screens standing there.
And a place is a set, not one id. A television at the head of a corridor serves both "1st secondary/1" and "2nd secondary/3"; a board in a wing serves two grades. A screen is given a set of one kind — classrooms together or grades together, never a mix — and it reaches you separated, so you lay them out however your screen wants (two columns, two tabs, one merged roll).
Declaring it — mobile.screen
One row per place, and each row names its own entry file:
"mobile": {
"screen": {
"enabled": true,
"title": "نداء الفصل",
"title_en": "Class roll call",
"min_contract": 21,
"auto_launch": true,
"places": [
{ "scope": "section", "entry": "okta_app/native/screen/lib/main.dart" },
{ "scope": "tenant", "entry": "okta_app/native/screen_lobby/lib/main.dart",
"title": "لوحة الإعلانات", "title_en": "Notice board" }
]
}
}
| Key | What it is |
|---|---|
places[] |
Required. One or two rows — one per place you serve. |
places[].scope |
Required. "section" (a classroom) or "tenant" (the whole organisation). Each place at most once; a repeat keeps the first row and the second is refused by name. There is no "grade" in the manifest — see the note below. |
places[].entry |
Required, per row. A Dart file under okta_app/native/<package>/lib/, in a package of its own. Exposes Widget main(). |
places[].title / title_en |
Optional. Overrides the block's title for this place alone — "Class roll call" in the classroom, "Notice board" in the lobby. |
title / title_en |
The default every row inherits when it names none of its own. Every place must end up with a title — its own or this one — or the declaration is refused. |
min_contract |
Optional. Host-contract floor for the screen code; omit to inherit mobile.minContract. Block-level, because it describes the app and not the place. |
auto_launch |
Optional, default true: a screen whose catalog holds only your app opens it by itself. Block-level too. |
Why an entry per place. The package is compiled on the device: a
screen downloads the source of the package declared for ITS place and builds
it there. One shared entry would therefore make every classroom television
download and compile the lobby code to draw a roll call it will never show.
A classroom screen and a lobby screen are different screens, and the
declaration says so: one place = one row = one package. The boilerplate ships
both — okta_app/native/screen/ (the roll call) and
okta_app/native/screen_lobby/ (the notice board).
The two places MAY share one file if you would rather ship a single
package that branches on Okta.context()['screen']: write the same path in
both rows. Then it is one artifact by your choice rather than by the
format's — and the platform knows it is one build, not two.
A screen placed on grades is served by a section declaration — you never
declare grade. An administrator can put a screen on particular grades (the
wing screen), but the manifest does not know the word grade and will not
learn it: no published manifest carries it, so requiring it would have
shipped the place dead — every existing screen app refused on the first wing
screen. A grade screen therefore takes the same section package, because
what it shows is what a classroom screen shows over a slightly wider set.
And a tenant declaration does not serve it: a lobby board addresses the
whole school, and putting it on a wing screen puts the wrong building on the
wall. If you want your app on wing screens, declare section.
The legacy flat shape still reads. The first version of this block was a
single entry with a flat scopes[]; if your manifest still has it, it is
read as one row per listed scope, every row carrying that file — a
manifest that published cleanly keeps publishing. It is never written back:
what is stored and published is always places[].
The block is independent of mobile.supported. An app that serves the
classroom screen and offers nothing on the phone declares screen alone and
leaves supported: false — and the repo sync, the editor and the MCP tool all
carry it without erasing it. It is embedded apps only: the screen compiles
your code from your signed source package, and an external app ships none.
What the screen receives — Okta.context()
The sandbox is the mini-app sandbox: the same Okta.*, the same okta_kit,
the same theme. A screen adds a few keys to Okta.context() and no new
symbol — so no contract bump and no higher minContract:
final ctx = Okta.context();
final String scope = '${ctx['screen']}'; // 'section', 'grade' or 'tenant'
// Every place, separated, in the order the administrator arranged them:
final List ids = (ctx['section_ids'] as List?) ?? const [];
final List names = (ctx['section_names'] as List?) ?? const [];
// And on a wing screen, the grades instead:
final List gradeIds = (ctx['grade_ids'] as List?) ?? const [];
final List gradeNames = (ctx['grade_names'] as List?) ?? const [];
// The singular stays, holding the FIRST place — for an app written before
// the set existed:
final dynamic sectionId = ctx['section_id'];
final dynamic sectionName = ctx['section_name'];
Read the lists, not the singular. A screen may stand in two classrooms,
and section_id names only the first — an app that reads it shows half of
what is on the wall, and nothing tells it so. The lists are absent when the
screen has no places of that kind (a lobby board, or a host older than the
set), so read them as above: as List? then an empty default. And never
split a joined string to get them — a classroom name may contain your
separator.
And on a wing screen the singular is absent entirely, not null: that
screen stands on grades, not classrooms, so neither section_id nor
section_name is in the map at all (and the reverse on a classroom screen:
no grade_id). Calling a method straight on the missing key —
ctx['section_id'].toString() and the like — kills your app inside the
interpreter with type 'Null' is not a subtype of type '$Instance' in type cast, and if it happens in initState it repeats every frame. This has
happened on a school wall. Read the lists, or check the singular before you
use it.
You will rarely need to name the places yourself: every student row from
/api/apps/education/students already carries its own classroom and grade, so
group them from that. The lists here are for drawing headings and ordering
columns, not for filtering — the filtering happens on the server (below).
['screen'] is still delivered — but it is no longer the routing
mechanism: the platform picks the entry declared for the place each screen
stands in, so the classroom package never runs anywhere but a classroom. It
stays useful for a file you deliberately pointed both places at (branch on
it), and for a guard that says "this is a lobby board hung on a classroom
screen" instead of drawing an empty one. Its value is 'grade' on a wing
screen — which receives the section package, as said above, so read
grade_names there if you draw a heading.
Read the keys with ['…'] as you read locale, and check for null
explicitly before interpolating (see the '$value' lesson in the native
section). tenant_id, locale and is_dark arrive as usual; role_id is
empty because a screen has no role.
A screen sees its own places only — enforced by the server
This is not advice for your code. A screen paired to classrooms reaches
student data through /api/apps/education/students pinned to its places on
the server:
- no filter at all → the pupils of its classrooms, not the school;
?section_id=naming a classroom outside them → an empty page;/students/{ulid}for a pupil outside them → 404, as if it did not exist;/education/sections→ its own classrooms.
A wing screen is the same one step wider: the pupils of its grades' classrooms, and a filter naming a grade or classroom outside them empties the page.
So a roll-call app on a classroom screen cannot list the school even if it asks. A filter naming a place outside the screen answers with an empty page, not an error — which is exactly what a classroom that does not exist answers, deliberately: telling the two apart would have made walking identifiers a way to learn what the school has.
A tenant screen sees what your phone app sees, under the same scopes the installation was granted — there are no screen-specific scopes.
And none of this needs a new scope or a contract bump: the places arrive in
Okta.context(), a map the host serialises as-is, and the filtering happens on
the server from the device's own row. Your app works as it is and declares
nothing new.
Example: class roll call (okta_app/native/screen/lib/main.dart)
import 'package:flutter/material.dart';
import 'package:okta_host/okta_host.dart';
import 'package:okta_kit/okta_kit.dart';
Widget main() => const RollCallScreen();
class RollCallScreen extends StatefulWidget {
const RollCallScreen({super.key});
@override
State<RollCallScreen> createState() => _RollCallScreenState();
}
class _RollCallScreenState extends State<RollCallScreen> {
List<dynamic> students = [];
@override
void initState() {
super.initState();
load();
}
Future<void> load() async {
// No filter: the server pins the request to the screen's classroom.
final res = await Okta.get('/api/apps/education/students?per_page=100');
if (res.ok) {
// body stays dynamic; OktaJson.rows unwraps the {data: [...]} envelope.
final dynamic body = res.body;
final List<dynamic> rows = OktaJson.rows(body);
setState(() { students = rows; });
}
}
@override
Widget build(BuildContext context) {
final dynamic ctx = Okta.context();
final dynamic section = ctx['section_name'];
final palette = OktaPalette.of(Okta.isDark());
final List<Widget> rows = <Widget>[];
for (final dynamic student in students) {
rows.add(Padding(
padding: const EdgeInsets.all(12),
child: Text(OktaJson.strOr(student, 'full_name', '—'),
style: TextStyle(fontSize: 36, color: palette.textStrong)),
));
}
return Scaffold(
backgroundColor: palette.surface,
appBar: OktaAppBar.build(section == null ? 'Roll call' : 'Roll call — $section', palette),
body: ListView(children: rows),
);
}
}
Example: the lobby notice board (okta_app/native/screen_lobby/lib/main.dart)
Another package and another file — not a branch inside the first one. Note
that it asks about no pupil at all: everyone walks past a lobby, so the
starter reads from your own endpoint (/api/<slug>/notices) and needs no
platform scope.
import 'package:flutter/material.dart';
import 'package:okta_host/okta_host.dart';
import 'package:okta_kit/okta_kit.dart';
Widget main() => const LobbyBoardScreen();
class LobbyBoardScreen extends StatefulWidget {
const LobbyBoardScreen({super.key});
@override
State<LobbyBoardScreen> createState() => _LobbyBoardScreenState();
}
class _LobbyBoardScreenState extends State<LobbyBoardScreen> {
List<dynamic> notices = [];
@override
void initState() {
super.initState();
load();
}
Future<void> load() async {
final res = await Okta.get('/api/my-app/notices');
if (res.ok) {
final dynamic body = res.body;
final List<dynamic> rows = OktaJson.rows(body);
setState(() { notices = rows; });
}
}
@override
Widget build(BuildContext context) {
final palette = OktaPalette.of(Okta.isDark());
final List<Widget> rows = <Widget>[];
for (final dynamic notice in notices) {
rows.add(Padding(
padding: const EdgeInsets.all(12),
child: Text(OktaJson.strOr(notice, 'title', '—'),
style: TextStyle(fontSize: 36, color: palette.textStrong)),
));
}
return Scaffold(
backgroundColor: palette.surface,
appBar: OktaAppBar.build('Notice board', palette),
body: ListView(children: rows),
);
}
}
The full versions — each with a refresh loop and its stop in dispose — ship
in the boilerplate under okta_app/native/screen/ and
okta_app/native/screen_lobby/.
Tips for the big screen: larger type than you would use on a phone (it is
read from the back of the room), no touch — do not rely on gestures or text
fields — and refresh on a schedule rather than pull-to-refresh. The screen
keeps your app open for hours, and Timer is not bridged on this runtime
(see item 14 in the native chapter), so the refresh loop calls itself again
after await Future.delayed(…) — never with a callback passed to it — with a
stop flag you set in dispose, and a long interval: every turn is a round trip to the host. Run
flutter test tool/validate.dart inside every screen package you ship:
the gate compiles one package, so a green classroom screen says nothing about
the lobby.
D-pad support and the menu key
No touch, ever, on a screen: control is a TV remote (direction keys + OK) or a keyboard on Windows (arrows + Enter). What follows was measured in this sandbox, not inferred from Flutter in general — the difference between the two matters a great deal here.
Stock widgets support the remote automatically, with no extra line of
code. ElevatedButton, TextButton and ListTile: arrows and Tab move
focus onto them, and OK/Enter (and the gamepad A button) fires the
onPressed/onTap inside your on-device-compiled code. That is all d-pad
support takes — use those widgets for anything the remote is meant to reach.
And a card you drew yourself is reachable too — since contract 25
(package:okta_focus). This is the new part; before it, no trick made it
possible:
import 'package:okta_focus/okta_focus.dart';
OktaFocus.first( // ← holds focus on the first frame
myBigCard(student), // ← anything you drew: Container, Row, image…
() => _call(student), // ← fires on OK/Enter, and on tap
)
OktaFocus.item(child, onSelect)— the ordinary one, and the one to reach for.OktaFocus.first(child, onSelect)— the same, and it holds focus on the first frame. One per screen: two widgets asking for the first focus is two answers to a question with one, and which wins is not something to build on.OktaFocus.watched(child, onSelect, onFocusChange)andwatchedFirst(...)— tell you when focus enters and leaves, so you can enlarge or tint your own tile.OktaFocus.reachable(child)— the arrows reach it, nothing is pressed: for a row in a long list the ring should travel through rather than skipping a block of content.
The host draws the focus ring, in the shell's colour, with its width
always laid out so gaining focus never nudges the row your tile sits in. You
do not draw it and cannot hide it: a board on a wall with no visible ring is
a board nobody in the room can navigate. Use watched to add your own
emphasis on top of it, never instead of it.
autofocus: true on a stock button still does nothing, because the
bridged button has no such parameter to arrive through. OktaFocus.first is
the answer.
Focus, FocusNode, FocusTraversalGroup, KeyboardListener and
PopScope do not exist here at all — each fails with a compile error
(Could not find declaration), not a runtime one. Two things follow:
- A bare
GestureDetectoris never reachable by the remote. A widget becomes a traversal target by owning aFocusNode, and there is no way to give it one withoutFocus. Touch and mouse on Windows still work on it, and a TV remote simply cannot reach it. Wrap it inOktaFocus.item(or put the action on aListTile) — never leave an action on a bareGestureDetectoron the screen surface. - You could not swallow the menu key even if you wanted to, and
OktaFocusdid not change that. Intercepting raw key events is structurally impossible from your side, not merely discouraged: the only key that reaches your code is select, and noKeyEventobject crosses into the sandbox at all. The arrows keep moving focus, and the menu key (Menu on the remote, or F1/Escape on a keyboard) and the back key always belong to the host and open its menu (the launcher, reload your app, settings) — the one way out of a lone app running in kiosk mode (auto_launch), where there is no "back" because there is nothing else to go back to.
And a passive screen with no buttons is a perfectly legitimate shape (a roll call, a notice board): the menu key keeps working because the host holds a fallback focus whenever nothing on your page holds one.
No MediaQuery and no LayoutBuilder — neither is available (the first
is not bridged, the second fails to compile like the rest above). Your
screen therefore cannot measure its own canvas at all, so design
straight to the fixed footprint: a 1080p television at ~320dpi reports a
logical size of about 960×540, not 1920×1080 — which is why the examples
above use type larger than you would on a phone (36pt is not an arbitrary
choice).
auto_launch describes your intent, not a guarantee. The screen's own
administrator holds a device-level switch ("always show the home screen")
that overrides auto_launch for every app on that particular screen, so
it always opens onto the grid of installed apps even when your app is the
only one declared there. Do not assume your app will simply appear with no
interaction: build its first screen so it reads fine whether the user
arrived there automatically or by tapping a tile on the grid — which is
exactly how it gets opened from the simulator and during review anyway.
Designing the screen surface — sizing, layout, and the back button
A screen is seen from across a room by many passing eyes, not held close by one — that flips the usual phone-design priorities:
- Design for the real baseline of roughly 960×540 logical px (a 1080p television at ~320dpi), not a phone canvas scaled up. Build for this footprint directly rather than designing a phone layout and stretching it by a fixed factor — proportions that read as balanced on a phone go either empty or crowded on a screen with a completely different, wide-and-short 16:9 aspect ratio.
- Leave a safety margin from the edges (overscan): some real TV panels — older ones especially — physically crop a few pixels off every edge. Never place text or an interactive element flush against the screen's edge; leave at least 24-32dp of margin around the entire content area.
- Type much larger than you would ever use on a phone — it is read from
the back of a classroom or a lobby, not arm's length. 36pt, used in this
chapter's examples, is a reasonable floor for body text, with headings
well above it. Use strong contrast between text and background (not close
shades of grey) — read colours through
OktaPalette.of(Okta.isDark())rather than hard-coding them, so your app follows the screen's own light/dark mode, which its administrator sets from that device's own settings, not from your app. - Few, large interactive elements — not a dense small grid: navigation
is entirely direction-key input on a remote, so every extra tile in a grid
is one more press for someone with no touch to jump straight to it.
Prefer a short list of large cards over a crowded grid of small ones. The
focus ring is drawn for you — by Flutter on stock widgets, by the host on
OktaFocustiles — so do not replace it with a faint hand-rolled highlight that is hard to make out from across a room. - Keep information density low, generally: a screen is usually a passive display (a roll call, a notice board, a live tally) glanced at in passing, not a dashboard someone studies closely. One clear message or one readable list beats four stat cards, a table and a chart crammed onto one page.
The remote's back button belongs to the host, not your app. The physical "back" key (Back on a TV remote, or Escape on a Windows keyboard) is intercepted entirely at the host level before it ever reaches your own widget tree, and its behaviour is fixed by how many apps are installed, not by your code:
- A single installed app (kiosk): back is swallowed completely — nothing happens, deliberately, so a passive display can't be nudged off its state by a stray press. The only way out is the menu key (see the previous section).
- More than one app: back sends the user straight to the app grid (the launcher) — your widget tree is never consulted and gets no notification of this at all.
So do not build internal navigation (a list drilling into a detail view,
say) that depends on the physical back key to go up a level — that event
never reaches your code in the sense phone apps usually assume. If your
screen needs a way out of a detail view back to a list, put a visible
"back" control on the screen itself — a stock button or a ListTile, not a
bare GestureDetector (see the remote-reachability rule above), placed
first in reading order, or carrying OktaFocus.first — and drive
it from your own widget state (setState), not from Navigator.
Trying the screen on the virtual device (the simulator)
You do not need a television to try it: the portal's «Virtual device» (the
app's tab, or /dashboard/simulator) mounts the very same screen surface in
the browser — the same dart_eval engine your code is compiled on by a
display, and the same source bundle from your branch.
- The «Display (Okta Screen)» surface appears in the surface picker when
the version declares
mobile.screen, and is selected automatically for an app that has no phone surface at all. - The place comes first: one row per declared place (organisation screen / classroom screen), each naming its own entry file. The simulator compiles the chosen place's file alone — exactly as a display does — so an app serving both places is tried twice, one package per place.
- The frame is a 960×540 logical television (dpr 2, no safe-area insets) — the size an Android TV really reports, not a phone turned sideways. The device panel offers other sizes for comparison, but the layout is judged at this one.
Okta.context()exactly as a display writes it, key for key:screen(tenant|section), for a classroomsection_idandsection_name, and an emptyrole_idbecause nobody signs in to a screen. The classroom is simulated (sim-section/ «Simulated classroom») — from the «Persona» panel you can type a real sandbox section's ULID and name if you want to follow its data. Changing the place or the section is a relaunch, not a repaint, because on a display too the context is injected at startup.- Branch source only: «Installed on sandbox» is disabled for a screen — no display is paired to the sandbox to download its bundle from.
- The remote is your keyboard: arrows and Tab move focus, Enter is OK. What an arrow cannot reach in the simulator, a remote cannot reach on the television either (see the D-pad section).
- A limit you must know — data is NOT pinned to the classroom here.
/api/appscalls in the simulator go through the sandbox seat (an admin account), not a screen device token, soeducation/studentsreturns the whole school, not the classroom. On a real display the server pins the section onto the context (sectionScopeId) and nothing else gets through — a server guarantee that does not depend on your code, and one that can only be tested on a paired screen. In the simulator, filter bysection_idyourself if you want to see what the classroom will see. - From MCP:
simulator_start {slug, surface: "screen", screen_scope: "section"}(or"tenant"), thensimulator_screenshot/simulator_ui/simulator_tapas for any session — the tool's reply names the place, the simulated classroom and the data limit above.
When your app fails on a screen — the error code and platform reporting
A screen nobody stands behind must neither go silently white nor show a red
error tree to a whole classroom. So when your app fails — a compile error, an
exception while building, a load timeout — the display shows a status screen
carrying an error code of the form MA-XXXXXXX (MA = mini-app, then a
fingerprint derived from the error's type and text — the same algorithm as
okta-app's codes, so the same failure yields the same code on every device).
The operator reads it off the television and reports it to you.
- The code reaches the platform on its own: the display reports the
failure to okta-web once per code per session (
POST /api/device/client-errorswith the paired device's token), with the message, the stack, your app'smodule_slugand the device's identity — so the failure is attributed to your app, not to the screen. - You read it on your app's «Errors» page in the portal, or through the
MCP tools:
recent_errorsfor the latest,app_errorsfor the triage list, andget_errorwith the code the operator read — from a code on a television to the full stack with nobody in between. - Repeats fold: failures with the same fingerprint land on one row with
a counter (
occurrences_count), so a hundred screens showing the same failure are one row, not a hundred reports. - What is not reported: an app that runs and shows something wrong is invisible to the platform — the report is for failures that prevent drawing. The way out on the device itself is the host menu (menu key → reload the app).
From the editor and MCP
- Simulator: the «Display» surface on the virtual device (section above),
and from MCP
simulator_startwithsurface: "screen"andscreen_scope. - Editor: the "In-Okta-app services" card → Display screens section — a checkbox per place that reveals its own entry file and its own title, and visible even with the phone surface switched off.
- MCP:
set_mobile_screenwrites the block (a full replace, ornullto remove it) and runs the publish checks early;get_mobile_surfacereads it back and prints a line per place with its entry. - Repo sync (
sync_from_manifest) carries it exactly as it carriesdashboard.