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) and watchedFirst(...) — 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 GestureDetector is never reachable by the remote. A widget becomes a traversal target by owning a FocusNode, and there is no way to give it one without Focus. Touch and mouse on Windows still work on it, and a TV remote simply cannot reach it. Wrap it in OktaFocus.item (or put the action on a ListTile) — never leave an action on a bare GestureDetector on the screen surface.
  • You could not swallow the menu key even if you wanted to, and OktaFocus did 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 no KeyEvent object 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 OktaFocus tiles — 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 classroom section_id and section_name, and an empty role_id because 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/apps calls in the simulator go through the sandbox seat (an admin account), not a screen device token, so education/students returns 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 by section_id yourself if you want to see what the classroom will see.
  • From MCP: simulator_start {slug, surface: "screen", screen_scope: "section"} (or "tenant"), then simulator_screenshot / simulator_ui / simulator_tap as 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-errors with the paired device's token), with the message, the stack, your app's module_slug and 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_errors for the latest, app_errors for the triage list, and get_error with 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_start with surface: "screen" and screen_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_screen writes the block (a full replace, or null to remove it) and runs the publish checks early; get_mobile_surface reads it back and prints a line per place with its entry.
  • Repo sync (sync_from_manifest) carries it exactly as it carries dashboard.

View the whole guide on one page · ↧ Raw Markdown