Skip to main content

hotlayout_flutter 0.31.0 copy "hotlayout_flutter: ^0.31.0" to clipboard
hotlayout_flutter: ^0.31.0 copied to clipboard

Flutter renderer for HotLayout server-driven UI. Draws screens published by the HotLayout builder from a JSON document, a theme and your data.

example/lib/main.dart

// The HotLayout quickstart, complete: a published document on screen, with a
// bundled copy underneath, data from the host, a host action and the device
// language.
//
//   flutter run                                   # bundled copies only
//   flutter run --dart-define=documentUrl=https://… --dart-define=themeUrl=https://…
import 'package:flutter/material.dart';
import 'package:hotlayout_flutter/hotlayout_flutter.dart';

import 'backend.dart';

/// The bundled copies, declared under `flutter: assets:` in pubspec.yaml.
const documentAsset = 'assets/hotlayout/document.json';
const themeAsset = 'assets/hotlayout/theme.json';

/// Where the published files live. Empty: the bundled copies are shown.
const documentUrl = String.fromEnvironment('documentUrl');
const themeUrl = String.fromEnvironment('themeUrl');

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final transport = HttpDocumentTransport();

  // The theme is read BEFORE the first frame: replacing colors after the
  // screen is drawn is a visible jump. Neither call throws.
  final themeLoad = themeUrl.isEmpty
      ? await ThemeLoad.fromBundle(assetPath: themeAsset)
      : await ThemeLoad.fromNetwork(
          Uri.parse(themeUrl),
          transport: transport,
          assetPath: themeAsset,
        );
  if (themeLoad.isFallback) {
    debugPrint('theme fallback: ${themeLoad.status.name} ${themeLoad.detail ?? ''}');
  }

  const bundled = BundledDocumentSource(title: 'Bundled copy', assetPath: documentAsset);
  final DocumentSource source = documentUrl.isEmpty
      ? bundled
      : PublishedDocumentSource(
          url: Uri.parse(documentUrl),
          fallback: bundled,
          transport: transport,
        );

  runApp(
    MaterialApp(
      title: 'HotLayout example',
      debugShowCheckedModeBanner: false,
      home: HotLayoutScreen(source: source, themeLoad: themeLoad, data: backendData),
    ),
  );
}

/// One HotLayout document as a screen of the app.
class HotLayoutScreen extends StatefulWidget {
  const HotLayoutScreen({
    super.key,
    required this.source,
    required this.themeLoad,
    required this.data,
    this.locale,
  });

  final DocumentSource source;
  final ThemeLoad themeLoad;

  /// Values for the paths the document is bound to.
  final Map<String, Object?> data;

  /// The language the user chose in the app; `null` follows the device.
  final String? locale;

  @override
  State<HotLayoutScreen> createState() => _HotLayoutScreenState();
}

class _HotLayoutScreenState extends State<HotLayoutScreen> with WidgetsBindingObserver {
  DocumentLoad? _load;

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
    _read();
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }

  Future<void> _read() async {
    // 1. Draw the bundled copy at once: no loading state.
    final seed = widget.source.seed;
    if (seed != null) _show(await seed.load());

    // 2. Ask the network. This never throws because of the network.
    _show(await widget.source.load());
  }

  void _show(DocumentLoad next) {
    if (!mounted) return;
    // A fallback is silent on screen by design, so say it in the log.
    debugPrint('document: ${next.status.name} ${next.detail ?? ''}');

    final previous = _load;
    // A refresh that failed keeps the published document on screen rather
    // than going back to the bundled copy.
    if (previous != null && next.isFallback && !previous.isFallback) return;
    // Most re-reads bring the same document: nothing to redraw. A new version
    // is drawn at once, and the screen stack stays while its screens exist.
    if (previous != null && previous.signature == next.signature) return;
    setState(() => _load = next);
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    // Back from the background: a manager may have published meanwhile.
    if (state == AppLifecycleState.resumed && widget.source.isRefreshable) {
      widget.source.load().then(_show);
    }
  }

  // The app's own actions, one handler per type. A handler gets the action,
  // the node and its `record`: the list row, or the row that opened this
  // screen (a flat map of paths, or null).
  late final _actions = HostActions(
    handlers: {
      // "Back" on the first screen of the document: leave the flow.
      ButtonAction.navigateBack: (call) => Navigator.of(context).maybePop(),
      'share': (call) => _say('Your share sheet opens here'),
      // Which reward? The row says so. Check the id on your backend like any
      // user input before you spend points on it.
      'redeem': (call) => _say('Redeem ${call.record?['item.name']} (${call.valueOf('item.id')})?'),
    },
    onMiss: (call, reason) => debugPrint('unhandled action ${call.type} from ${call.nodeId}: ${reason.name}'),
  );

  // What the user typed or switched in a `TextField`, `Switch`, `Checkbox` or
  // `ChoiceGroup`: the data path of the field and a String, a bool or (a number
  // `TextField`) a num; an empty String when the number was cleared. Called on
  // every change, at once. It is user input: check it on your backend like any
  // other, and throttle here if a network stands behind it.
  void _onWrite(String path, Object? value) {
    switch (value) {
      case String text:
        debugPrint('text field $path: ${text.length} characters');
      case bool on:
        debugPrint('switch or checkbox $path: $on');
      case num number:
        debugPrint('number field $path: $number');
    }
  }

  void _say(String message) {
    ScaffoldMessenger.of(context)
      ..hideCurrentSnackBar()
      ..showSnackBar(SnackBar(content: Text(message)));
  }

  @override
  Widget build(BuildContext context) {
    // The contract marks every theme light or dark: no theme ids in app code.
    final theme = widget.themeLoad.contract.themeForBrightness(
      MediaQuery.platformBrightnessOf(context),
    );

    final load = _load;
    if (load == null) return ColoredBox(color: theme.toMaterialTheme().colorScheme.surface);
    final document = load.document;

    // The document matches the device languages against its own list:
    // `ar-EG` finds `ar`. A user's choice wins over the device.
    final locale =
        widget.locale ??
        document.localization?.localeFor(WidgetsBinding.instance.platformDispatcher.locales);

    return Theme(
      data: theme.toMaterialTheme(),
      // The renderer draws only the document: the Scaffold and the safe area
      // are the host's.
      child: Scaffold(
        body: SafeArea(
          child: renderDocument(
            document,
            theme: theme,
            data: widget.data,
            locale: locale,
            onRecordAction: _actions.handle,
            onWrite: _onWrite,
            // Documents from a source you do not fully trust: no autofill hints.
            // autofill: false,
          ),
        ),
      ),
    );
  }
}
0
likes
160
points
66
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Flutter renderer for HotLayout server-driven UI. Draws screens published by the HotLayout builder from a JSON document, a theme and your data.

Homepage
Repository (GitHub)
View/report issues
Contributing

Topics

#server-driven-ui #sdui #ui #widget

License

Apache-2.0 (license)

Dependencies

flutter, flutter_svg, http, intl, vector_graphics_compiler

More

Packages that depend on hotlayout_flutter