hotlayout_flutter 0.30.0
hotlayout_flutter: ^0.30.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,
),
),
),
);
}
}