usesmileid_njia 0.0.1-SNAPSHOT.13
usesmileid_njia: ^0.0.1-SNAPSHOT.13 copied to clipboard
Smile ID's flow navigation engine for Flutter: a thin bridge to the native njia cores, which own the back stack. Only navigation crosses the bridge.
njia for Flutter #
The usesmileid_njia package, built a layer at a time (AGENTS.md, "Small pull requests"). The
reducer doesn't run in Dart: the package starts an engine in the native core (njia-android,
njia-ios) and exchanges graphs, intents and transitions with it as JSON (SPEC §11), so the
typed vocabulary and the codec live here and the routing doesn't. So far:
| Library | What it holds |
|---|---|
package:usesmileid_njia/usesmileid_njia.dart |
NjiaHost, the Flutter host (SPEC §10.3), with NjiaStepScope and NjiaNavigator. NjiaEngine, the native engine driven from Dart through the bridge (SPEC §11): start, restore, openLink, send, preview, listeners and dispose, with refusals raised as njia's exceptions. The graph, generic over the SDK's step kinds K and facts F (NjiaGraph, NjiaNode, NjiaStep, NjiaSegment, NjiaCondition, NjiaGuard, NjiaIntentKind), its validate() with the SPEC §4.4 issue codes, the state (NjiaState, NjiaEntry, NjiaPhase, NjiaOutcome), the intents (NjiaIntent), the effects and transitions (NjiaEffect, NjiaTransition), and NjiaCodec, which maps them to the wire's strings through a NjiaWire (SPEC §4, §9). NjiaDefaults, and NjiaException with NjiaJsonException and NjiaInvalidGraphException. Under them, NjiaJsonText, the strict JSON reader and writer (SPEC §4.5). The Flutter SDK only |
A graph in your own types #
enum Kind { capture, preview, consent }
enum Fact { needsBack, backDone }
final codec = NjiaCodec(kinds: NjiaWire.ofEnum(Kind.values), facts: NjiaWire.ofEnum(Fact.values));
final NjiaGraph<Kind, Fact> graph = codec.decodeGraph(json);
final again = NjiaCondition.allOf([Fact.needsBack]).butNot([Fact.backDone]);
NjiaCodec.strings reads the same graph with plain strings, which is what the bridge uses. Where
Kotlin and Swift write NjiaWire.enum() and allOf(…), Dart writes NjiaWire.ofEnum(values) and
NjiaCondition.allOf([…]): enum is a reserved word, and a top-level allOf would clash with
package:matcher's in every test that imports both.
Dart can't nest a case inside its type the way Kotlin writes NjiaPhase.Busy, so each case of a
sealed type is named <Case><Type> (NjiaBusyPhase) for pattern matching, and built through the
type (NjiaPhase.busy(reason)).
Driving the native engine #
final codec = NjiaCodec(kinds: NjiaWire.ofEnum(Kind.values), facts: NjiaWire.ofEnum(Fact.values));
final engine = await NjiaKit.start(codec, graph, facts: {Fact.needsBack});
engine.addListener((transition) => setState(() {}));
await engine.send(NjiaIntent.next(from: engine.state.top!.id));
Showing a flow #
NjiaHost(
engine: engine,
builder: (context, scope) => switch (scope.step.kind) {
Kind.capture => CaptureScreen(isTop: scope.isTop, onDone: scope.navigator.next),
Kind.preview => PreviewScreen(onRetake: scope.navigator.back, onAccept: scope.navigator.next),
Kind.consent => ConsentScreen(onAgree: scope.navigator.next),
},
)
NjiaHost is a Navigator whose pages are the engine's entries, keyed by entry id, with the shared
step motion and the njia.step.<step> semantics identifier on each (SPEC §10). The system back, and
a step that pops its own route, send back from the entry on top. On iOS a screen also swipes back from
its leading edge, while a back from it would pop it.
The calls cross a Pigeon channel (pigeons/njia_api.dart, regenerated with pigeons/generate.sh)
to the core's NjiaBridge, so they complete asynchronously, in order. The plugin takes the core
at its own release:
-
iOS: the plugin is a Swift package (
ios/usesmileid_njia) that depends on theNjiaproduct of njia-spm, pinned exactly to this release, so enableflutter config --enable-swift-package-manager. Inside this repository, send njia-spm to the core in your checkout with a local mirror, which CI uses too:bash scripts/local_njia_spm_mirror.sh /tmp/njia-spm \ njia-flutter/example/ios/Runner.xcworkspace njia-flutter/example/ios/Runner.xcodeprojIt writes a
mirrors.jsoninto each, which git ignores; delete them to use the public package. -
Android: it depends on
com.usesmileid:njiaat this release. Inside this repository that is njia-android's-SNAPSHOT, from./gradlew :njia:publishToMavenLocal.
example/ runs the integration tests against the real engines on both.
Answers #
A step returns a value with its navigator's answer, and the flow moves on. The entry keeps the
answer until it is popped (SPEC §6). Dart has no overloads, so the typed calls are answerAs:
final plan = NjiaAnswerFormat<Plan>.of(encode: (plan) => plan.name, decode: Plan.values.byName);
// In the partner's step:
await scope.navigator.answerAs(Plan.gold, plan);
// In the SDK, once the flow has moved on:
final chosen = engine.state.answerAs('partner.plan', plan);
Registering steps #
Instead of switching on the step's kind, register each kind once, with shared decorators and
per-entry dependencies, and give the registry to the host (SPEC §10). Dart has no overloads, so
it's NjiaHost.withSteps:
final uploads = NjiaDependencyKey<UploadClient>('uploads');
final steps = NjiaSteps<Kind, Fact>((steps) => steps
..decorate((context, scope, content) => StepScaffold(child: content))
..provide(uploads, (scope) => sdk.uploadClient)
..step(Kind.capture, (context, scope) => CaptureScreen(session: scope.retained('camera', CameraSession.new)))
..step(Kind.preview, (context, scope) => PreviewScreen(uploads: scope.dependency(uploads))));
NjiaHost.withSteps(engine: engine, steps: steps);
A graph with a kind the registry can't draw throws StateError before anything is drawn;
steps.missing(graph) finds it in a test. A dependency is never closed, since a provider may hand
every entry one shared client; a resource the entry owns goes in retained, which keeps it until
the entry is popped and closes it then if it's NjiaClosable. NjiaPausable builds a covered step
with its last value, since the navigator keeps covered routes alive.
Tracing #
Give an engine a trace when it's created, and attach its document to a bug report. It keeps the last records (the creation, then every intent with its effects and state), redacting answers and outcome details by default (SPEC §9):
final trace = NjiaTrace<Kind, Fact>(capacity: 64);
final engine = await NjiaKit.start(codec, graph, trace: trace);
// In a bug report:
final json = trace.document(codec);
python3 scripts/replay_trace.py trace.json checks a document against the reference reducer.
Building #
Flutter 3.44 (Dart 3.12), with the package floor at Dart 3.11 (AGENTS.md).
flutter pub get
dart format --output=none --set-exit-if-changed . # format
flutter analyze --fatal-infos # lint
flutter test # unit tests, reading ../conformance directly
./scripts/coverage.sh # tests with line coverage ≥ 95%