.md

Wrapping pub.dev packages

The point of rendering with Flutter isn't just pixels — it's pub.dev: tens of thousands of maintained native plugins. Skal's codegen turns any Flutter widget into a JSX component and any plugin API into an awaitable JS call. No platform channels, no per-plugin JS glue.

Widgets — one line of config

List the package in your app's skal_codegen.yaml:

# flutter-host/lib/skal_codegen.yaml
packages:
  - qr_flutter
  - flutter_map

Then run the generator:

$ bun run codegen

The builder introspects each package's public widgets with Dart's analyzer — constructors, named constructors, parameter types — and emits adapters plus a manifest. On the JS side, typed components appear under skal-flutter:

import { QrImageView, FlutterMap, TileLayer } from 'skal-flutter';

<QrImageView data="https://skal.dev" size={220} />
Named constructors surface as their own components — shimmer's Shimmer.fromColors becomes <ShimmerFromColors>. Prop types round-trip: enums, colors, durations, edge insets, gradients, text styles, value classes, even List<Marker>-style value children as JSX children.

Headless capabilities — services

Most native capabilities have no widget: geolocation, biometrics, share, clipboard, permissions. Declare a service and the class's static methods become awaitable RPCs; a $-suffixed call subscribes a Dart Stream and returns an unsubscribe function:

# skal_codegen.yaml
services:
  geo:
    package: geolocator
    class: Geolocator
import { createSkalService } from 'skal/runtime';

const geo = createSkalService('geo');
const pos = await geo.getCurrentPosition();
const stop = geo.getPositionStream$((p) => setPos(p));

geolocator wraps with zero hand-written Dart — its API is already static. A plugin with an instance-shaped API (local_auth) needs one tiny static forwarder class, and that forwarder is walked by codegen like any package:

// flutter-host/lib/adapters/auth_service.dart — the ENTIRE integration
class AuthService {
  static final _auth = LocalAuthentication();
  static Future<bool> authenticate(String reason) =>
      _auth.authenticate(localizedReason: reason);
}

And services aren't just plugin wrappers — they're the general "run this in Dart, call it from JS" hatch for your own logic. Crypto is the canonical case: native-speed hashing, heavy work on a background isolate, keys and file bytes that never enter JS (big inputs cross as paths, never payloads):

// flutter-host/lib/adapters/crypto_service.dart   deps: crypto: ^3.0.0
class CryptoService {
  static String sha256Hex(String input) =>
      sha256.convert(utf8.encode(input)).toString();
  static Future<String> sha256File(String path) => Isolate.run(
      () async => (await sha256.bind(File(path).openRead()).last).toString());
}

Stateful widgets — hosts

Widgets whose constructor needs a live controller (camera preview, webview, video) use the host pattern: you write a ~15-line factory function; its parameters become JSX props, and the controller's public methods become imperative calls on a ref — one RPC op out, the reply back through the reply heap:

import { createSkalRef } from 'skal/runtime';

const cam = createSkalRef();
<Camera ref={cam} cameraName="back" />
await cam.takePicture();
cam.frames$((f) => ...);   // Dart Streams subscribe with $

The skip report

After every run, anything codegen could not map is listed in lib/skal_codegen.json under "skipped" — with the reason and the exact remedy. Never diff generated output to find out what's missing; read the report. Many skips are rescued with zero Dart via overrides: in the same yaml — pin a generic widget's type arguments, supply a const: expression for one unmappable parameter, mark an indexed builder with builder: true, or hold a controller as an opaque JS-side handle with handle: true.

The escape-hatch ladder

When codegen can't map a library, escalate one rung at a time — every rung is a shipped pattern, and the line counts below are from real plugins:

  1. overrides: in the yaml — one bad param, generic type args. 0 lines of Dart.
  2. Static forwarder + services: — instance-shaped plugin APIs. ~10–25 lines (local_auth: 24).
  3. Platform-quirk shim — a platform needs an argument codegen can't encode (share_plus wants a sharePositionOrigin rect on iPad/iOS 26). ~8 lines.
  4. hosts: factory — live controller, async init. ~15–20 lines.
  5. A plain local widget class — write normal Flutter in lib/adapters/; codegen walks your code like any pub package. Same manifest, same 'skal-flutter' import.
  6. Raw registrySkalRegistry.registerWidget(name, (n, bridge) => …) for full manual control. Last resort; props read untyped off the node.
Hand-written Dart is codegen input, not a parallel system — a forwarder or local widget gets the same typed prop readers, stream plumbing, and JSX import synthesis as any pub package. Zero JS-side glue at every rung except the last.

Permissions

Declare intent once in flutter-host/skal-permissions.json:

{ "camera": "Scan QR codes", "location": "Show places near you" }

bun run link translates it into every platform dialect — iOS + macOS Info.plist usage strings, macOS App Sandbox entitlements, AndroidManifest entries, and permission_handler's PERMISSION_* Podfile macros (without which every request answers permanentlyDenied). Idempotent, add-only; your hand-edited wording is never reverted.

When codegen isn't enough

  • Non-void callbacks (bool Function(T)) need a synchronous JS→Dart return, which would block the frame — permanently out of scope. Restructure around a prop or a ref method.
  • Builders keyed by a domain object (not an index) have nothing to key a subtree by — indexed builders work via builder: true.
  • Payloads are for data, not blobs — strings beyond the heap sizes truncate. Pass paths and handles, not file contents.