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 the Njia product of njia-spm, pinned exactly to this release, so enable flutter 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.xcodeproj
    

    It writes a mirrors.json into each, which git ignores; delete them to use the public package.

  • Android: it depends on com.usesmileid:njia at 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%

Libraries

usesmileid_njia
njia, Smile ID's flow navigation engine, for Flutter (SPEC §11).