Examples & reference
Example: an attendance-by-scan app (native) — from nothing to published
One practical chapter that builds a complete Dart mini-app: a supervisor stands
at the gate, waves student cards past the phone, and every read is recorded as
an attendance — and kept on the device when the network drops. A real example,
because it is what actually gets built, and because it touches everything a
native app needs: state, pages, permissions, scanning, the network, storage,
the three states, and the identity.
Before you write a line: four limits that shape the design
These are not preferences. Each one changes what your app can be.
1. There is no text input. TextField is not in the bridged surface. A
native app is a read-and-act app: it shows, it scans, it taps, it sends.
Any screen whose whole point is that someone types into it — a registration
form, free-text search, a comment — is not a native app; build it in
webview or external mode. Discovering this two weeks into the work is the
most expensive lesson on the platform, which is why it is the first line here.
2. There is no navigation. Navigator.of is declared in the bridge with no
implementation and no static dispatch entry, so the NavigatorState can never
be obtained from inside the sandbox, and there is no route stack there either.
Two screens inside your app means one piece of state holding a page number —
the full pattern is below.
3. No nested loops. dart_eval 0.8.5 throws a compile-time RangeError
when a loop body contains another loop — directly, or through a function it
calls. Flatten hierarchical data into one list, on the server where you can.
4. A shadow and a rounded corner cannot coexist. Rounding works only through
ClipRRect, shadows only through BoxDecoration, and BoxDecoration carries
no borderRadius. When a surface has to lift and be round, use
OktaGlass.card — the host draws that one natively and it gets all three.
0 · Layout and manifest
okta_app/native/attendance/
├── pubspec.yaml ← pins okta_miniapp — do not bump the ref yourself
├── analysis_options.yaml
├── lib/
│ └── main.dart ← the entry point: Widget main()
└── tool/
└── validate.dart ← the same gate CI runs
And the manifest:
"mobile": {
"supported": true,
"mode": "native",
"entry": "okta_app/native/attendance/lib/main.dart",
"minContract": 16
}
minContract: 16 because we use package:okta_glass. Nothing else to declare:
device access needs no manifest entry since the capability gate was removed.
1 · State, and the trap that costs a day
StatefulWidget and setState work. Two traps:
setState(() { _busy = true; }); // block body
setState(() => _busy = true); // boxes twice and throws
An arrow closure returns its body's value, and an assignment is an
expression — so the value is boxed once to write the field and again to return
it, and the second box lands on something already boxed:
type '$bool' is not a subtype of type 'bool'. Convert every site, not only
the ones that crash: whether the double box lands turns on register allocation,
not on anything visible in the source.
The second: there is no State.mounted — it is unbridged, and naming it
fails to compile. Call setState directly.
2 · Internal pages — the pattern, without Navigator
A page is a number in state, the bar decides what it shows, and the back button goes back internally and then hands over to the host when the pages run out:
class _AttendanceState extends State<Attendance> {
int _page = 0; // 0 = scan · 1 = today's log
void _openLog() {
setState(() { _page = 1; });
}
// One back button for every page. It goes back internally while there is
// somewhere to go, and hands over to the host on the first page — which is
// the only way out of a mini-app.
void _back() {
if (_page != 0) {
setState(() { _page = 0; });
return;
}
Okta.close();
// Doing nothing is a valid host response to close (there may be nothing to
// pop), so never rely on it to stop execution — return straight after.
return;
}
AppBar _bar(OktaPalette palette) {
if (_page == 0) {
return OktaAppBar.build('Attendance', palette);
}
return OktaAppBar.withBack(
"Today's log", palette, Okta.appIcon(22.0), () => _back());
}
}
Do not put the icon in
leadingon the first page. Flutter inserts a back button only whenleading == null, soOktaAppBar.buildWithIconsilently removes the only way out.withBacktakes the slot back for the button and moves the icon intoactions.
3 · Scan — and read what comes back
There is no permission call to make first: the platform's capability gate has been removed, so a scan is just a scan. What has NOT changed is that it can come back empty, and that is the part worth writing code for.
Future<void> _scan() async {
final key = await Okta.scanNfc();
if (key == null) {
// null covers every ending that is not a card: the user cancelled, the
// phone has no NFC, the OS permission dialog was declined. None of them is
// an exception, and none of them should be announced as an error.
return;
}
await _submit(key);
}
4 · Sending — and three endings, not two
OktaApiResponse carries status, body, error and the computed ok. And
the difference everyone forgets: status == 0 means the call never reached a
server at all. Showing "the server returned an error" then is a lie to the
supervisor — the server said nothing.
Future<void> _submit(String key) async {
final res = await Okta.post(
'/api/attendance/reads',
<String, dynamic>{'key': key},
);
if (res.ok) {
Okta.playSound('success');
// body is dynamic end to end — never give the receiver a Map type.
final name = OktaJson.strOr(res.body, 'student_name', key);
setState(() { _log.add('$name · present'); });
return;
}
if (res.status == 0) {
await _queue(key);
return;
}
Okta.playSound('error');
setState(() { _log.add('refused · ${res.status}'); });
}
5 · The local queue — and the silent failure
Future<void> _queue(String key) async {
await Okta.storePut('queued:$key', key);
Okta.playSound('warning');
// Say it out loud. The local capture is confirmed before the network is
// consulted — which is right, it is how a scan survives an outage — but it
// means an upload that never arrived looks **exactly** like one that did.
// Someone standing at a gate is looking at the student, not at a badge in a
// header, so the difference is told with a sound, a message and a log line.
Okta.toast('Saved on the device — no network. It will send when it returns.');
setState(() { _log.add('$key · waiting for the network'); });
}
Okta.storeKeys() and Okta.storeDelete(key) drain the queue later.
Storage is not secure storage. Treat it as readable by anyone holding the device: it is for a capture queue, not for a token.
6 · The list and the three states
Widget _logPage(OktaPalette palette) {
if (_loading) {
return OktaStates.loading(palette);
}
if (_log.isEmpty) {
return OktaStates.empty(
OktaIcons.list(), 'No reads yet', 'Wave a card to start.', palette);
}
// One loop, and a row builder with no loop inside it. A loop within a loop —
// directly or through a function it calls — fails compilation with RangeError.
final rows = <Widget>[];
for (final entry in _log) {
rows.add(OktaGlass.card(
OktaList.row(OktaIcons.check(), entry, 'Today', palette),
));
}
return ListView(
padding: const EdgeInsets.all(OktaMetrics.space4),
children: rows,
);
}
7 · Identity — without one colour literal
final palette = OktaPalette.of(Okta.isDark());
Scaffold(
backgroundColor: const Color(0x00000000),
appBar: _bar(palette),
body: OktaGlass.background( // one field for the whole screen
_page == 0 ? _scanPage(palette) : _logPage(palette),
),
)
See "Okta's identity inside a mini-app" above for the detail. The rule here is
one line: every colour from palette, every number from OktaMetrics, every
component from okta_kit — so when Okta restyles the material your app changes
without you publishing anything.
8 · Validate and publish
flutter test tool/validate.dart # the same gate CI runs
dart run tool/audit_bridge_usage.dart lib/main.dart # catches the silently dropped
flutter analyzewill complain thatOktaandOktaPaletteare undefined — that is expected. The libraries are injected by the engine at compile time and are not real pub packages.
Then create a new version. The on-device bytecode cache is keyed by
(slug, entry, payloadVersion, runtimeSignature) and never by the content of
the bundle: reinstalling without moving any of those keeps the old copy, with no
message and no warning — you open the app, see the old behaviour, and conclude
your fix did not work, when it never reached the device at all. See "The
mini-app is cached" above for what each part means.
The whole file
import 'package:flutter/material.dart';
import 'package:okta_glass/okta_glass.dart';
import 'package:okta_host/okta_host.dart';
import 'package:okta_kit/okta_kit.dart';
Widget main() => const Attendance();
class Attendance extends StatefulWidget {
const Attendance({super.key});
@override
State<Attendance> createState() => _AttendanceState();
}
class _AttendanceState extends State<Attendance> {
int _page = 0;
bool _busy = false;
final List<String> _log = <String>[];
void _openLog() {
setState(() { _page = 1; });
}
void _back() {
if (_page != 0) {
setState(() { _page = 0; });
return;
}
Okta.close();
return;
}
Future<void> _scan() async {
if (_busy) {
return;
}
setState(() { _busy = true; });
final key = await Okta.scanNfc();
if (key == null) {
setState(() { _busy = false; });
return;
}
final res = await Okta.post(
'/api/attendance/reads',
<String, dynamic>{'key': key},
);
if (res.ok) {
Okta.playSound('success');
final name = OktaJson.strOr(res.body, 'student_name', key);
setState(() {
_log.add('$name · present');
_busy = false;
});
return;
}
if (res.status == 0) {
await Okta.storePut('queued:$key', key);
Okta.playSound('warning');
Okta.toast('Saved on the device — no network. It will send when it returns.');
setState(() {
_log.add('$key · waiting for the network');
_busy = false;
});
return;
}
Okta.playSound('error');
setState(() {
_log.add('refused · ${res.status}');
_busy = false;
});
}
AppBar _bar(OktaPalette palette) {
if (_page == 0) {
return OktaAppBar.build('Attendance', palette);
}
return OktaAppBar.withBack(
"Today's log", palette, Okta.appIcon(22.0), () => _back());
}
Widget _scanPage(OktaPalette palette) {
return ListView(
padding: const EdgeInsets.all(OktaMetrics.space4),
children: <Widget>[
OktaGlass.card(
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text(
'Hold the student card against the phone',
style: TextStyle(
color: palette.textStrong,
fontSize: OktaMetrics.textBody,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: OktaMetrics.space4),
_busy
? OktaButton.disabled('Scanning…', palette)
: OktaButton.primary('Start scanning', () => _scan(), palette),
],
),
),
const SizedBox(height: OktaMetrics.space3),
OktaSurface.cardTappable(
palette,
() => _openLog(),
OktaList.rowWithTrailing(
OktaIcons.list(),
"Today's log",
'Everything recorded since the day began',
OktaSurface.pill('${_log.length}', palette.brandSoft, palette.brand),
palette,
),
),
],
);
}
Widget _logPage(OktaPalette palette) {
if (_log.isEmpty) {
return OktaStates.empty(
OktaIcons.list(), 'No reads yet', 'Wave a card to start.', palette);
}
final rows = <Widget>[];
for (final entry in _log) {
rows.add(Padding(
padding: const EdgeInsets.only(bottom: OktaMetrics.space2),
child: OktaGlass.card(
OktaList.row(OktaIcons.check(), entry, 'Today', palette),
),
));
}
return ListView(
padding: const EdgeInsets.all(OktaMetrics.space4),
children: rows,
);
}
@override
Widget build(BuildContext context) {
final palette = OktaPalette.of(Okta.isDark());
return Directionality(
textDirection: OktaText.isRtl(Okta.locale())
? TextDirection.rtl
: TextDirection.ltr,
child: Scaffold(
backgroundColor: const Color(0x00000000),
appBar: _bar(palette),
body: OktaGlass.background(
_page == 0 ? _scanPage(palette) : _logPage(palette),
),
),
);
}
}