diff --git a/packages/rfw/CHANGELOG.md b/packages/rfw/CHANGELOG.md index 5b2b6cbbfd61..b34abdb06489 100644 --- a/packages/rfw/CHANGELOG.md +++ b/packages/rfw/CHANGELOG.md @@ -1,3 +1,6 @@ +## 1.0.25 +* Adds support for wildget builders. + ## 1.0.24 * Adds `InkResponse` material widget. diff --git a/packages/rfw/lib/src/dart/binary.dart b/packages/rfw/lib/src/dart/binary.dart index f36690f2c7d0..cd999377f207 100644 --- a/packages/rfw/lib/src/dart/binary.dart +++ b/packages/rfw/lib/src/dart/binary.dart @@ -298,6 +298,8 @@ const int _msEvent = 0x0E; const int _msSwitch = 0x0F; const int _msDefault = 0x10; const int _msSetState = 0x11; +const int _msWidgetBuilder = 0x12; +const int _msWidgetBuilderArgReference = 0x13; /// API for decoding Remote Flutter Widgets binary blobs. /// @@ -453,6 +455,10 @@ class _BlobDecoder { return _readSwitch(); case _msSetState: return SetStateHandler(StateReference(_readPartList()), _readArgument()); + case _msWidgetBuilder: + return _readWidgetBuilder(); + case _msWidgetBuilderArgReference: + return WidgetBuilderArgReference(_readString(), _readPartList()); default: return _parseValue(type, _readArgument); } @@ -468,6 +474,16 @@ class _BlobDecoder { return ConstructorCall(name, _readMap(_readArgument)!); } + WidgetBuilderDeclaration _readWidgetBuilder() { + final String argumentName = _readString(); + final int type = _readByte(); + if (type != _msWidget && type != _msSwitch) { + throw FormatException('Unrecognized data type 0x${type.toRadixString(16).toUpperCase().padLeft(2, "0")} while decoding widget builder blob.'); + } + final BlobNode widget = type == _msWidget ? _readWidget() : _readSwitch(); + return WidgetBuilderDeclaration(argumentName, widget); + } + WidgetDeclaration _readDeclaration() { final String name = _readString(); final DynamicMap? initialState = _readMap(readValue, nullIfEmpty: true); @@ -613,6 +629,10 @@ class _BlobEncoder { bytes.addByte(_msWidget); _writeString(value.name); _writeMap(value.arguments, _writeArgument); + } else if (value is WidgetBuilderDeclaration) { + bytes.addByte(_msWidgetBuilder); + _writeString(value.argumentName); + _writeArgument(value.widget); } else if (value is ArgsReference) { bytes.addByte(_msArgsReference); _writeInt64(value.parts.length); @@ -621,6 +641,11 @@ class _BlobEncoder { bytes.addByte(_msDataReference); _writeInt64(value.parts.length); value.parts.forEach(_writePart); + } else if (value is WidgetBuilderArgReference) { + bytes.addByte(_msWidgetBuilderArgReference); + _writeString(value.argumentName); + _writeInt64(value.parts.length); + value.parts.forEach(_writePart); } else if (value is LoopReference) { bytes.addByte(_msLoopReference); _writeInt64(value.loop); diff --git a/packages/rfw/lib/src/dart/model.dart b/packages/rfw/lib/src/dart/model.dart index b6a6b9921263..22be39c3acb7 100644 --- a/packages/rfw/lib/src/dart/model.dart +++ b/packages/rfw/lib/src/dart/model.dart @@ -439,6 +439,28 @@ class ConstructorCall extends BlobNode { String toString() => '$name($arguments)'; } +/// Representation of functions that return widgets in Remote Flutter Widgets library blobs. +class WidgetBuilderDeclaration extends BlobNode { + /// Represents a callback that takes a single argument [argumentName] and returns the [widget]. + const WidgetBuilderDeclaration(this.argumentName, this.widget); + + /// The callback single argument name. + /// + /// In `Builder(builder: (scope) => Container());`, [argumentName] is "scope". + final String argumentName; + + /// The widget that will be returned when the builder callback is called. + /// + /// This is usually a [ConstructorCall], but may be a [Switch] (so long as + /// that [Switch] resolves to a [ConstructorCall]. Other values (or a [Switch] + /// that does not resolve to a constructor call) will result in an + /// [ErrorWidget] being used. + final BlobNode widget; + + @override + String toString() => '($argumentName) => $widget'; +} + /// Base class for various kinds of references in the RFW data structures. abstract class Reference extends BlobNode { /// Abstract const constructor. This constructor enables subclasses to provide @@ -534,6 +556,31 @@ class DataReference extends Reference { String toString() => 'data.${parts.join(".")}'; } +/// Reference to the single argument of type [DynamicMap] passed into the widget builder. +/// +/// This class is used to represent references to a function argument. +/// In `(scope) => Container(width: scope.width)`, this represents "scope.width". +/// +/// See also: +/// +/// * [WidgetBuilderDeclaration], which represents a widget builder definition. +class WidgetBuilderArgReference extends Reference { + /// Wraps the given [argumentName] and [parts] as a [WidgetBuilderArgReference]. + /// + /// The parts must not be mutated after the object is created. + const WidgetBuilderArgReference(this.argumentName, super.parts); + + /// A reference to a [WidgetBuilderDeclaration.argumentName]. + /// + /// In `Builder(builder: (scope) => Text(text: scope.result.text));`, + /// "scope.result.text" is the [WidgetBuilderArgReference]. + /// The [argumentName] is "scope" and its [parts] are `["result", "text"]`. + final String argumentName; + + @override + String toString() => '$argumentName.${parts.join('.')}'; +} + /// Unbound reference to a [Loop]. class LoopReference extends Reference { /// Wraps the given [loop] and [parts] as a [LoopReference]. diff --git a/packages/rfw/lib/src/dart/text.dart b/packages/rfw/lib/src/dart/text.dart index 88e0843ef3dd..4db42dcfbe0d 100644 --- a/packages/rfw/lib/src/dart/text.dart +++ b/packages/rfw/lib/src/dart/text.dart @@ -272,8 +272,8 @@ DynamicMap parseDataFile(String file) { /// declaration, along with its arguments. Arguments are a map of key-value /// pairs, where the values can be any of the types in the data model defined /// above plus any of the types defined below in this section, such as -/// references to arguments, the data model, loops, state, switches, or -/// event handlers. +/// references to arguments, the data model, widget builders, loops, state, +/// switches or event handlers. /// /// In this example, several constructor calls are nested together: /// @@ -283,6 +283,9 @@ DynamicMap parseDataFile(String file) { /// Container( /// child: Text(text: "Hello"), /// ), +/// Builder( +/// builder: (scope) => Text(text: scope.world), +/// ), /// ], /// ); /// ``` @@ -293,6 +296,35 @@ DynamicMap parseDataFile(String file) { /// constructor call also has only one argument, `child`, whose value, again, is /// a constructor call, in this case creating a `Text` widget. /// +/// ### Widget Builders +/// +/// Widget builders take a single argument and return a widget. +/// The [DynamicMap] argument consists of key-value pairs where values +/// can be of any types in the data model. Widget builders arguments are lexically +/// scoped so a given constructor call has access to any arguments where it is +/// defined plus arguments defined by its parents (if any). +/// +/// In this example several widget builders are nested together: +/// +/// ``` +/// widget Foo {text: 'this is cool'} = Builder( +/// builder: (foo) => Builder( +/// builder: (bar) => Builder( +/// builder: (baz) => Text( +/// text: [ +/// args.text, +/// state.text, +/// data.text, +/// foo.text, +/// bar.text, +/// baz.text, +/// ], +/// ), +/// ), +/// ), +/// ); +/// ``` +/// /// ### References /// /// Remote widget libraries typically contain _references_, e.g. to the @@ -610,6 +642,12 @@ const Set _reservedWords = { 'true', }; +void _checkIsNotReservedWord(String identifier, _Token identifierToken) { + if (_reservedWords.contains(identifier)) { + throw ParserException._fromToken('$identifier is a reserved word', identifierToken); + } +} + sealed class _Token { _Token(this.line, this.column, this.start, this.end); final int line; @@ -630,6 +668,7 @@ class _SymbolToken extends _Token { static const int colon = 0x3A; // U+003A COLON character (:) static const int semicolon = 0x3B; // U+003B SEMICOLON character (;) static const int equals = 0x3D; // U+003D EQUALS SIGN character (=) + static const int greatherThan = 0x3E; // U+003D GREATHER THAN character (>) static const int openBracket = 0x5B; // U+005B LEFT SQUARE BRACKET character ([) static const int closeBracket = 0x5D; // U+005D RIGHT SQUARE BRACKET character (]) static const int openBrace = 0x7B; // U+007B LEFT CURLY BRACKET character ({) @@ -812,6 +851,7 @@ Iterable<_Token> _tokenize(String file) sync* { case 0x3A: // U+003A COLON character (:) case 0x3B: // U+003B SEMICOLON character (;) case 0x3D: // U+003D EQUALS SIGN character (=) + case 0x3E: // U+003E GREATHER THAN SIGN character (>) case 0x5B: // U+005B LEFT SQUARE BRACKET character ([) case 0x5D: // U+005D RIGHT SQUARE BRACKET character (]) case 0x7B: // U+007B LEFT CURLY BRACKET character ({) @@ -2132,14 +2172,23 @@ class _Parser { return _readString(); } - DynamicMap _readMap({ required bool extended }) { + DynamicMap _readMap({ + required bool extended, + List widgetBuilderScope = const [], + }) { _expectSymbol(_SymbolToken.openBrace); - final DynamicMap results = _readMapBody(extended: extended); + final DynamicMap results = _readMapBody( + widgetBuilderScope: widgetBuilderScope, + extended: extended, + ); _expectSymbol(_SymbolToken.closeBrace); return results; } - DynamicMap _readMapBody({ required bool extended }) { + DynamicMap _readMapBody({ + required bool extended, + List widgetBuilderScope = const [], + }) { final DynamicMap results = DynamicMap(); // ignore: prefer_collection_literals while (_source.current is! _SymbolToken) { final String key = _readKey(); @@ -2147,7 +2196,11 @@ class _Parser { throw ParserException._fromToken('Duplicate key "$key" in map', _source.current); } _expectSymbol(_SymbolToken.colon); - final Object value = _readValue(extended: extended, nullOk: true); + final Object value = _readValue( + extended: extended, + nullOk: true, + widgetBuilderScope: widgetBuilderScope, + ); if (value != missing) { results[key] = value; } @@ -2162,7 +2215,10 @@ class _Parser { final List _loopIdentifiers = []; - DynamicList _readList({ required bool extended }) { + DynamicList _readList({ + required bool extended, + List widgetBuilderScope = const [], + }) { final DynamicList results = DynamicList.empty(growable: true); _expectSymbol(_SymbolToken.openBracket); while (!_foundSymbol(_SymbolToken.closeBracket)) { @@ -2172,19 +2228,26 @@ class _Parser { _expectIdentifier('for'); final _Token loopIdentifierToken = _source.current; final String loopIdentifier = _readIdentifier(); - if (_reservedWords.contains(loopIdentifier)) { - throw ParserException._fromToken('$loopIdentifier is a reserved word', loopIdentifierToken); - } + _checkIsNotReservedWord(loopIdentifier, loopIdentifierToken); _expectIdentifier('in'); - final Object collection = _readValue(extended: true); + final Object collection = _readValue( + widgetBuilderScope: widgetBuilderScope, + extended: true, + ); _expectSymbol(_SymbolToken.colon); _loopIdentifiers.add(loopIdentifier); - final Object template = _readValue(extended: extended); + final Object template = _readValue( + widgetBuilderScope: widgetBuilderScope, + extended: extended, + ); assert(_loopIdentifiers.last == loopIdentifier); _loopIdentifiers.removeLast(); results.add(_withSourceRange(Loop(collection, template), start)); } else { - final Object value = _readValue(extended: extended); + final Object value = _readValue( + widgetBuilderScope: widgetBuilderScope, + extended: extended, + ); results.add(value); } if (_foundSymbol(_SymbolToken.comma)) { @@ -2197,8 +2260,10 @@ class _Parser { return results; } - Switch _readSwitch(SourceLocation? start) { - final Object value = _readValue(extended: true); + Switch _readSwitch(SourceLocation? start, { + List widgetBuilderScope = const [], + }) { + final Object value = _readValue(extended: true, widgetBuilderScope: widgetBuilderScope); final Map cases = {}; _expectSymbol(_SymbolToken.openBrace); while (_source.current is! _SymbolToken) { @@ -2210,13 +2275,13 @@ class _Parser { key = null; _advance(); } else { - key = _readValue(extended: true); + key = _readValue(extended: true, widgetBuilderScope: widgetBuilderScope); if (cases.containsKey(key)) { throw ParserException._fromToken('Switch has duplicate cases for key $key', _source.current); } } _expectSymbol(_SymbolToken.colon); - final Object value = _readValue(extended: true); + final Object value = _readValue(extended: true, widgetBuilderScope: widgetBuilderScope); cases[key] = value; if (_foundSymbol(_SymbolToken.comma)) { _advance(); @@ -2249,13 +2314,19 @@ class _Parser { return results; } - Object _readValue({ required bool extended, bool nullOk = false }) { + Object _readValue({ + required bool extended, + bool nullOk = false, + List widgetBuilderScope = const [], + }) { if (_source.current is _SymbolToken) { switch ((_source.current as _SymbolToken).symbol) { case _SymbolToken.openBracket: - return _readList(extended: extended); + return _readList(widgetBuilderScope: widgetBuilderScope, extended: extended); case _SymbolToken.openBrace: - return _readMap(extended: extended); + return _readMap(widgetBuilderScope: widgetBuilderScope, extended: extended); + case _SymbolToken.openParen: + return _readWidgetBuilderDeclaration(widgetBuilderScope: widgetBuilderScope); } } else if (_source.current is _IntegerToken) { final Object result = (_source.current as _IntegerToken).value; @@ -2289,7 +2360,13 @@ class _Parser { if (identifier == 'event') { final SourceLocation? start = _getSourceLocation(); _advance(); - return _withSourceRange(EventHandler(_readString(), _readMap(extended: true)), start); + return _withSourceRange( + EventHandler( + _readString(), + _readMap(widgetBuilderScope: widgetBuilderScope, extended: true), + ), + start, + ); } if (identifier == 'args') { final SourceLocation? start = _getSourceLocation(); @@ -2309,7 +2386,7 @@ class _Parser { if (identifier == 'switch') { final SourceLocation? start = _getSourceLocation(); _advance(); - return _readSwitch(start); + return _readSwitch(start, widgetBuilderScope: widgetBuilderScope); } if (identifier == 'set') { final SourceLocation? start = _getSourceLocation(); @@ -2318,25 +2395,56 @@ class _Parser { _expectIdentifier('state'); final StateReference stateReference = _withSourceRange(StateReference(_readParts()), innerStart); _expectSymbol(_SymbolToken.equals); - final Object value = _readValue(extended: true); + final Object value = _readValue(widgetBuilderScope: widgetBuilderScope, extended: true); return _withSourceRange(SetStateHandler(stateReference, value), start); } + if (widgetBuilderScope.contains(identifier)) { + final SourceLocation? start = _getSourceLocation(); + _advance(); + return _withSourceRange(WidgetBuilderArgReference(identifier, _readParts()), start); + } final int index = _loopIdentifiers.lastIndexOf(identifier) + 1; if (index > 0) { final SourceLocation? start = _getSourceLocation(); _advance(); return _withSourceRange(LoopReference(_loopIdentifiers.length - index, _readParts(optional: true)), start); } - return _readConstructorCall(); + return _readConstructorCall(widgetBuilderScope: widgetBuilderScope); } throw ParserException._unexpected(_source.current); } - ConstructorCall _readConstructorCall() { + WidgetBuilderDeclaration _readWidgetBuilderDeclaration({ + List widgetBuilderScope = const [], + }) { + _expectSymbol(_SymbolToken.openParen); + final _Token argumentNameToken = _source.current; + final String argumentName = _readIdentifier(); + _checkIsNotReservedWord(argumentName, argumentNameToken); + _expectSymbol(_SymbolToken.closeParen); + _expectSymbol(_SymbolToken.equals); + _expectSymbol(_SymbolToken.greatherThan); + final _Token valueToken = _source.current; + final Object widget = _readValue( + extended: true, + widgetBuilderScope: [...widgetBuilderScope, argumentName], + ); + if (widget is! ConstructorCall && widget is! Switch) { + throw ParserException._fromToken('Expecting a switch or constructor call got $widget', valueToken); + } + return WidgetBuilderDeclaration(argumentName, widget as BlobNode); + } + + ConstructorCall _readConstructorCall({ + List widgetBuilderScope = const [], + }) { final SourceLocation? start = _getSourceLocation(); final String name = _readIdentifier(); _expectSymbol(_SymbolToken.openParen); - final DynamicMap arguments = _readMapBody(extended: true); + final DynamicMap arguments = _readMapBody( + extended: true, + widgetBuilderScope: widgetBuilderScope, + ); _expectSymbol(_SymbolToken.closeParen); return _withSourceRange(ConstructorCall(name, arguments), start); } diff --git a/packages/rfw/lib/src/flutter/runtime.dart b/packages/rfw/lib/src/flutter/runtime.dart index 7d874e78e8b2..f66b7cd22547 100644 --- a/packages/rfw/lib/src/flutter/runtime.dart +++ b/packages/rfw/lib/src/flutter/runtime.dart @@ -19,6 +19,9 @@ import 'content.dart'; /// [LocalWidgetBuilder] callbacks. typedef LocalWidgetBuilder = Widget Function(BuildContext context, DataSource source); +/// Signature of builders for remote widgets. +typedef _RemoteWidgetBuilder = _CurriedWidget Function(DynamicMap builderArg); + /// Signature of the callback passed to a [RemoteWidget]. /// /// This is used by [RemoteWidget] and [Runtime.build] as the callback for @@ -126,6 +129,25 @@ abstract class DataSource { /// non-widget nodes replaced by [ErrorWidget]. List childList(List argsKey); + /// Builds the widget builder at the given key. + /// + /// If the node is not a widget builder, returns an [ErrorWidget]. + /// + /// See also: + /// + /// * [optionalBuilder], which returns null if the widget builder is missing. + Widget builder(List argsKey, DynamicMap builderArg); + + /// Builds the widget builder at the given key. + /// + /// If the node is not a widget builder, returns null. + /// + /// See also: + /// + /// * [builder], which returns an [ErrorWidget] instead of null if the widget + /// builder is missing. + Widget? optionalBuilder(List argsKey, DynamicMap builderArg); + /// Gets a [VoidCallback] event handler at the given key. /// /// If the node specified is an [AnyEventHandler] or a [DynamicList] of @@ -284,11 +306,23 @@ class Runtime extends ChangeNotifier { /// /// The `remoteEventTarget` argument is the callback that the RFW runtime will /// invoke whenever a remote widget event handler is triggered. - Widget build(BuildContext context, FullyQualifiedWidgetName widget, DynamicContent data, RemoteEventHandler remoteEventTarget) { + Widget build( + BuildContext context, + FullyQualifiedWidgetName widget, + DynamicContent data, + RemoteEventHandler remoteEventTarget, + ) { _CurriedWidget? boundWidget = _widgets[widget]; if (boundWidget == null) { _checkForImportLoops(widget.library); - boundWidget = _applyConstructorAndBindArguments(widget, const {}, -1, {}, null); + boundWidget = _applyConstructorAndBindArguments( + widget, + const {}, + const {}, + -1, + {}, + null, + ); _widgets[widget] = boundWidget; } return boundWidget.build(context, data, remoteEventTarget, const <_WidgetState>[]); @@ -410,13 +444,22 @@ class Runtime extends ChangeNotifier { /// [LocalWidgetBuilder] rather than a [WidgetDeclaration], and is used to /// provide source information for local widgets (which otherwise could not be /// associated with a part of the source). See also [Runtime.blobNodeFor]. - _CurriedWidget _applyConstructorAndBindArguments(FullyQualifiedWidgetName fullName, DynamicMap arguments, int stateDepth, Set usedWidgets, BlobNode? source) { + _CurriedWidget _applyConstructorAndBindArguments( + FullyQualifiedWidgetName fullName, + DynamicMap arguments, + DynamicMap widgetBuilderScope, + int stateDepth, + Set usedWidgets, + BlobNode? source, + ) { final _ResolvedConstructor? widget = _findConstructor(fullName); if (widget != null) { if (widget.constructor is WidgetDeclaration) { if (usedWidgets.contains(widget.fullName)) { - return _CurriedLocalWidget.error(fullName, 'Widget loop: Tried to call ${widget.fullName} constructor reentrantly.') - ..propagateSource(source); + return _CurriedLocalWidget.error( + fullName, + 'Widget loop: Tried to call ${widget.fullName} constructor reentrantly.', + )..propagateSource(source); } usedWidgets = usedWidgets.toSet()..add(widget.fullName); final WidgetDeclaration constructor = widget.constructor as WidgetDeclaration; @@ -426,22 +469,43 @@ class Runtime extends ChangeNotifier { } else { newDepth = stateDepth; } - Object result = _bindArguments(widget.fullName, constructor.root, arguments, newDepth, usedWidgets); + Object result = _bindArguments( + widget.fullName, + constructor.root, + arguments, + widgetBuilderScope, + newDepth, + usedWidgets, + ); if (result is Switch) { - result = _CurriedSwitch(widget.fullName, result, arguments, constructor.initialState) - ..propagateSource(result); + result = _CurriedSwitch( + widget.fullName, + result, + arguments, + widgetBuilderScope, + constructor.initialState, + )..propagateSource(result); } else { result as _CurriedWidget; if (constructor.initialState != null) { - result = _CurriedRemoteWidget(widget.fullName, result, arguments, constructor.initialState) - ..propagateSource(result); + result = _CurriedRemoteWidget( + widget.fullName, + result, + arguments, + widgetBuilderScope, + constructor.initialState, + )..propagateSource(result); } } return result as _CurriedWidget; } assert(widget.constructor is LocalWidgetBuilder); - return _CurriedLocalWidget(widget.fullName, widget.constructor as LocalWidgetBuilder, arguments) - ..propagateSource(source); + return _CurriedLocalWidget( + widget.fullName, + widget.constructor as LocalWidgetBuilder, + arguments, + widgetBuilderScope, + )..propagateSource(source); } final Set missingLibraries = _findMissingLibraries(fullName.library).toSet(); if (missingLibraries.isNotEmpty) { @@ -455,37 +519,93 @@ class Runtime extends ChangeNotifier { ..propagateSource(source); } - Object _bindArguments(FullyQualifiedWidgetName context, Object node, Object arguments, int stateDepth, Set usedWidgets) { + Object _bindArguments( + FullyQualifiedWidgetName context, + Object node, Object arguments, + DynamicMap widgetBuilderScope, + int stateDepth, + Set usedWidgets, + ) { if (node is ConstructorCall) { - final DynamicMap subArguments = _bindArguments(context, node.arguments, arguments, stateDepth, usedWidgets) as DynamicMap; - return _applyConstructorAndBindArguments(FullyQualifiedWidgetName(context.library, node.name), subArguments, stateDepth, usedWidgets, node); + final DynamicMap subArguments = _bindArguments( + context, + node.arguments, + arguments, + widgetBuilderScope, + stateDepth, + usedWidgets, + ) as DynamicMap; + return _applyConstructorAndBindArguments( + FullyQualifiedWidgetName(context.library, node.name), + subArguments, + widgetBuilderScope, + stateDepth, + usedWidgets, + node, + ); } + if (node is WidgetBuilderDeclaration) { + return (DynamicMap widgetBuilderArg) { + final DynamicMap newWidgetBuilderScope = { + ...widgetBuilderScope, + node.argumentName: widgetBuilderArg, + }; + final Object result = _bindArguments( + context, + node.widget, + arguments, + newWidgetBuilderScope, + stateDepth, + usedWidgets, + ); + if (result is Switch) { + return _CurriedSwitch( + FullyQualifiedWidgetName(context.library, ''), + result, + arguments as DynamicMap, + newWidgetBuilderScope, + const {}, + )..propagateSource(result); + } + return result as _CurriedWidget; + }; + } if (node is DynamicMap) { return node.map( - (String name, Object? value) => MapEntry(name, _bindArguments(context, value!, arguments, stateDepth, usedWidgets)), + (String name, Object? value) => MapEntry( + name, + _bindArguments(context, value!, arguments, widgetBuilderScope, stateDepth, usedWidgets), + ), ); } if (node is DynamicList) { return List.generate( node.length, - (int index) => _bindArguments(context, node[index]!, arguments, stateDepth, usedWidgets), + (int index) => _bindArguments( + context, + node[index]!, + arguments, + widgetBuilderScope, + stateDepth, + usedWidgets, + ), growable: false, ); } if (node is Loop) { - final Object input = _bindArguments(context, node.input, arguments, stateDepth, usedWidgets); - final Object output = _bindArguments(context, node.output, arguments, stateDepth, usedWidgets); + final Object input = _bindArguments(context, node.input, arguments, widgetBuilderScope, stateDepth, usedWidgets); + final Object output = _bindArguments(context, node.output, arguments, widgetBuilderScope, stateDepth, usedWidgets); return Loop(input, output) ..propagateSource(node); } if (node is Switch) { return Switch( - _bindArguments(context, node.input, arguments, stateDepth, usedWidgets), + _bindArguments(context, node.input, arguments, widgetBuilderScope, stateDepth, usedWidgets), node.outputs.map( (Object? key, Object value) { return MapEntry( - key == null ? key : _bindArguments(context, key, arguments, stateDepth, usedWidgets), - _bindArguments(context, value, arguments, stateDepth, usedWidgets), + key == null ? key : _bindArguments(context, key, arguments, widgetBuilderScope, stateDepth, usedWidgets), + _bindArguments(context, value, arguments, widgetBuilderScope, stateDepth, usedWidgets), ); }, ), @@ -498,14 +618,25 @@ class Runtime extends ChangeNotifier { return node.bind(stateDepth)..propagateSource(node); } if (node is EventHandler) { - return EventHandler(node.eventName, _bindArguments(context, node.eventArguments, arguments, stateDepth, usedWidgets) as DynamicMap) - ..propagateSource(node); + return EventHandler( + node.eventName, + _bindArguments( + context, + node.eventArguments, + arguments, + widgetBuilderScope, + stateDepth, + usedWidgets, + ) as DynamicMap, + )..propagateSource(node); } if (node is SetStateHandler) { assert(node.stateReference is StateReference); final BoundStateReference stateReference = (node.stateReference as StateReference).bind(stateDepth); - return SetStateHandler(stateReference, _bindArguments(context, node.value, arguments, stateDepth, usedWidgets)) - ..propagateSource(node); + return SetStateHandler( + stateReference, + _bindArguments(context, node.value, arguments, widgetBuilderScope, stateDepth, usedWidgets), + )..propagateSource(node); } assert(node is! WidgetDeclaration); return node; @@ -528,12 +659,19 @@ class _ResolvedDynamicList { typedef _DataResolverCallback = Object Function(List dataKey); typedef _StateResolverCallback = Object Function(List stateKey, int depth); +typedef _WidgetBuilderArgResolverCallback = Object Function(List argKey); abstract class _CurriedWidget extends BlobNode { - const _CurriedWidget(this.fullName, this.arguments, this.initialState); + const _CurriedWidget( + this.fullName, + this.arguments, + this.widgetBuilderScope, + this.initialState, + ); final FullyQualifiedWidgetName fullName; final DynamicMap arguments; + final DynamicMap widgetBuilderScope; final DynamicMap? initialState; static Object _bindLoopVariable(Object node, Object argument, int depth) { @@ -569,6 +707,7 @@ abstract class _CurriedWidget extends BlobNode { node.fullName, node.child, _bindLoopVariable(node.arguments, argument, depth) as DynamicMap, + _bindLoopVariable(node.widgetBuilderScope, argument, depth) as DynamicMap, )..propagateSource(node); } if (node is _CurriedRemoteWidget) { @@ -576,6 +715,7 @@ abstract class _CurriedWidget extends BlobNode { node.fullName, _bindLoopVariable(node.child, argument, depth) as _CurriedWidget, _bindLoopVariable(node.arguments, argument, depth) as DynamicMap, + _bindLoopVariable(node.widgetBuilderScope, argument, depth) as DynamicMap, node.initialState, )..propagateSource(node); } @@ -584,6 +724,7 @@ abstract class _CurriedWidget extends BlobNode { node.fullName, _bindLoopVariable(node.root, argument, depth) as Switch, _bindLoopVariable(node.arguments, argument, depth) as DynamicMap, + _bindLoopVariable(node.widgetBuilderScope, argument, depth) as DynamicMap, node.initialState, )..propagateSource(node); } @@ -615,7 +756,13 @@ abstract class _CurriedWidget extends BlobNode { // // TODO(ianh): This really should have some sort of caching. Right now, evaluating a whole list // ends up being around O(N^2) since we have to walk the list from the start for every entry. - static _ResolvedDynamicList _listLookup(DynamicList list, int targetEffectiveIndex, _StateResolverCallback stateResolver, _DataResolverCallback dataResolver) { + static _ResolvedDynamicList _listLookup( + DynamicList list, + int targetEffectiveIndex, + _StateResolverCallback stateResolver, + _DataResolverCallback dataResolver, + _WidgetBuilderArgResolverCallback widgetBuilderArgResolver, + ) { int currentIndex = 0; // where we are in `list` (some entries of which might represent multiple values, because they are themselves loops) int effectiveIndex = 0; // where we are in the fully expanded list (the coordinate space in which we're aiming for `targetEffectiveIndex`) while ((effectiveIndex <= targetEffectiveIndex || targetEffectiveIndex < 0) && currentIndex < list.length) { @@ -624,22 +771,46 @@ abstract class _CurriedWidget extends BlobNode { Object inputList = node.input; while (inputList is! DynamicList) { if (inputList is BoundArgsReference) { - inputList = _resolveFrom(inputList.arguments, inputList.parts, stateResolver, dataResolver); + inputList = _resolveFrom( + inputList.arguments, + inputList.parts, + stateResolver, + dataResolver, + widgetBuilderArgResolver, + ); } else if (inputList is DataReference) { inputList = dataResolver(inputList.parts); } else if (inputList is BoundStateReference) { inputList = stateResolver(inputList.parts, inputList.depth); } else if (inputList is BoundLoopReference) { - inputList = _resolveFrom(inputList.value, inputList.parts, stateResolver, dataResolver); + inputList = _resolveFrom( + inputList.value, + inputList.parts, + stateResolver, + dataResolver, + widgetBuilderArgResolver, + ); } else if (inputList is Switch) { - inputList = _resolveFrom(inputList, const [], stateResolver, dataResolver); + inputList = _resolveFrom( + inputList, + const [], + stateResolver, + dataResolver, + widgetBuilderArgResolver, + ); } else { // e.g. it's a map or something else that isn't indexable inputList = DynamicList.empty(); } assert(inputList is! _ResolvedDynamicList); } - final _ResolvedDynamicList entry = _listLookup(inputList, targetEffectiveIndex >= 0 ? targetEffectiveIndex - effectiveIndex : -1, stateResolver, dataResolver); + final _ResolvedDynamicList entry = _listLookup( + inputList, + targetEffectiveIndex >= 0 ? targetEffectiveIndex - effectiveIndex : -1, + stateResolver, + dataResolver, + widgetBuilderArgResolver, + ); if (entry.result != null) { final Object boundResult = _bindLoopVariable(node.output, entry.result!, 0); return _ResolvedDynamicList(null, boundResult, null); @@ -656,7 +827,13 @@ abstract class _CurriedWidget extends BlobNode { return _ResolvedDynamicList(list, null, effectiveIndex); } - static Object _resolveFrom(Object root, List parts, _StateResolverCallback stateResolver, _DataResolverCallback dataResolver) { + static Object _resolveFrom( + Object root, + List parts, + _StateResolverCallback stateResolver, + _DataResolverCallback dataResolver, + _WidgetBuilderArgResolverCallback widgetBuilderArgResolver, + ) { int index = 0; Object current = root; while (true) { @@ -667,6 +844,9 @@ abstract class _CurriedWidget extends BlobNode { } current = dataResolver(current.parts); continue; + } else if (current is WidgetBuilderArgReference) { + current = widgetBuilderArgResolver([current.argumentName, ...current.parts]); + continue; } else if (current is BoundArgsReference) { List nextParts = current.parts; if (index < parts.length) { @@ -693,7 +873,13 @@ abstract class _CurriedWidget extends BlobNode { index = 0; continue; } else if (current is Switch) { - final Object key = _resolveFrom(current.input, const [], stateResolver, dataResolver); + final Object key = _resolveFrom( + current.input, + const [], + stateResolver, + dataResolver, + widgetBuilderArgResolver, + ); Object? value = current.outputs[key]; if (value == null) { value = current.outputs[null]; @@ -707,9 +893,15 @@ abstract class _CurriedWidget extends BlobNode { // We've reached the end of the line. // We handle some special leaf cases that still need processing before we return. if (current is EventHandler) { - current = EventHandler(current.eventName, _fix(current.eventArguments, stateResolver, dataResolver) as DynamicMap); + current = EventHandler( + current.eventName, + _fix(current.eventArguments, stateResolver, dataResolver, widgetBuilderArgResolver) as DynamicMap, + ); } else if (current is SetStateHandler) { - current = SetStateHandler(current.stateReference, _fix(current.value, stateResolver, dataResolver)); + current = SetStateHandler( + current.stateReference, + _fix(current.value, stateResolver, dataResolver, widgetBuilderArgResolver), + ); } // else `current` is nothing special, and we'll just return it below. break; // This is where the loop ends. @@ -725,7 +917,13 @@ abstract class _CurriedWidget extends BlobNode { if (parts[index] is! int) { return missing; } - current = _listLookup(current, parts[index] as int, stateResolver, dataResolver).result ?? missing; + current = _listLookup( + current, + parts[index] as int, + stateResolver, + dataResolver, + widgetBuilderArgResolver, + ).result ?? missing; } else { assert(current is! ArgsReference); assert(current is! StateReference); @@ -740,27 +938,60 @@ abstract class _CurriedWidget extends BlobNode { return current; } - static Object _fix(Object root, _StateResolverCallback stateResolver, _DataResolverCallback dataResolver) { + static Object _fix( + Object root, + _StateResolverCallback stateResolver, + _DataResolverCallback dataResolver, + _WidgetBuilderArgResolverCallback widgetBuilderArgResolver, + ) { if (root is DynamicMap) { - return root.map((String key, Object? value) => MapEntry(key, _fix(root[key]!, stateResolver, dataResolver))); + return root.map((String key, Object? value) => + MapEntry( + key, + _fix(root[key]!, stateResolver, dataResolver, widgetBuilderArgResolver), + ), + ); } else if (root is DynamicList) { if (root.any((Object? entry) => entry is Loop)) { - final int length = _listLookup(root, -1, stateResolver, dataResolver).length!; - return DynamicList.generate(length, (int index) => _fix(_listLookup(root, index, stateResolver, dataResolver).result!, stateResolver, dataResolver)); + final int length = _listLookup( + root, + -1, + stateResolver, + dataResolver, + widgetBuilderArgResolver, + ).length!; + return DynamicList.generate( + length, + (int index) => _fix( + _listLookup(root, index, stateResolver, dataResolver, widgetBuilderArgResolver).result!, + stateResolver, + dataResolver, + widgetBuilderArgResolver, + ), + ); } else { - return DynamicList.generate(root.length, (int index) => _fix(root[index]!, stateResolver, dataResolver)); + return DynamicList.generate( + root.length, + (int index) => _fix(root[index]!, stateResolver, dataResolver, widgetBuilderArgResolver), + ); } } else if (root is BlobNode) { - return _resolveFrom(root, const [], stateResolver, dataResolver); + return _resolveFrom(root, const [], stateResolver, dataResolver, widgetBuilderArgResolver); } else { return root; } } - Object resolve(List parts, _StateResolverCallback stateResolver, _DataResolverCallback dataResolver, { required bool expandLists }) { - Object result = _resolveFrom(arguments, parts, stateResolver, dataResolver); + Object resolve( + List parts, + _StateResolverCallback stateResolver, + _DataResolverCallback dataResolver, + _WidgetBuilderArgResolverCallback widgetBuilderArgResolver, { + required bool expandLists, + }) { + Object result = _resolveFrom(arguments, parts, stateResolver, dataResolver, widgetBuilderArgResolver); if (result is DynamicList && expandLists) { - result = _listLookup(result, -1, stateResolver, dataResolver); + result = _listLookup(result, -1, stateResolver, dataResolver, widgetBuilderArgResolver); } assert(result is! Reference); assert(result is! Switch); @@ -768,38 +999,92 @@ abstract class _CurriedWidget extends BlobNode { return result; } - Widget build(BuildContext context, DynamicContent data, RemoteEventHandler remoteEventTarget, List<_WidgetState> states) { - return _Widget(curriedWidget: this, data: data, remoteEventTarget: remoteEventTarget, states: states); + Widget build( + BuildContext context, + DynamicContent data, + RemoteEventHandler remoteEventTarget, + List<_WidgetState> states, + ) { + return _Widget( + curriedWidget: this, + data: data, + widgetBuilderScope: DynamicContent(widgetBuilderScope), + remoteEventTarget: remoteEventTarget, + states: states, + ); } - Widget buildChild(BuildContext context, DataSource source, DynamicContent data, RemoteEventHandler remoteEventTarget, List<_WidgetState> states, _StateResolverCallback stateResolver, _DataResolverCallback dataResolver); + Widget buildChild( + BuildContext context, + DataSource source, + DynamicContent data, + RemoteEventHandler remoteEventTarget, + List<_WidgetState> states, + _StateResolverCallback stateResolver, + _DataResolverCallback dataResolver, + _WidgetBuilderArgResolverCallback widgetBuilderArgResolver, + ); @override String toString() => '$fullName ${initialState ?? "{}"} $arguments'; } class _CurriedLocalWidget extends _CurriedWidget { - const _CurriedLocalWidget(FullyQualifiedWidgetName fullName, this.child, DynamicMap arguments) : super(fullName, arguments, null); + const _CurriedLocalWidget( + FullyQualifiedWidgetName fullName, + this.child, + DynamicMap arguments, + DynamicMap widgetBuilderScope, + ) : super(fullName, arguments, widgetBuilderScope, null); factory _CurriedLocalWidget.error(FullyQualifiedWidgetName fullName, String message) { - return _CurriedLocalWidget(fullName, (BuildContext context, DataSource data) => _buildErrorWidget(message), const {}); + return _CurriedLocalWidget( + fullName, + (BuildContext context, DataSource data) => _buildErrorWidget(message), + const {}, + const {}, + ); } final LocalWidgetBuilder child; @override - Widget buildChild(BuildContext context, DataSource source, DynamicContent data, RemoteEventHandler remoteEventTarget, List<_WidgetState> states, _StateResolverCallback stateResolver, _DataResolverCallback dataResolver) { + Widget buildChild( + BuildContext context, + DataSource source, + DynamicContent data, + RemoteEventHandler remoteEventTarget, + List<_WidgetState> states, + _StateResolverCallback stateResolver, + _DataResolverCallback dataResolver, + _WidgetBuilderArgResolverCallback widgetBuilderArgResolver, + ) { return child(context, source); } } class _CurriedRemoteWidget extends _CurriedWidget { - const _CurriedRemoteWidget(FullyQualifiedWidgetName fullName, this.child, DynamicMap arguments, DynamicMap? initialState) : super(fullName, arguments, initialState); + const _CurriedRemoteWidget( + FullyQualifiedWidgetName fullName, + this.child, + DynamicMap arguments, + DynamicMap widgetBuilderScope, + DynamicMap? initialState, + ) : super(fullName, arguments, widgetBuilderScope, initialState); final _CurriedWidget child; @override - Widget buildChild(BuildContext context, DataSource source, DynamicContent data, RemoteEventHandler remoteEventTarget, List<_WidgetState> states, _StateResolverCallback stateResolver, _DataResolverCallback dataResolver) { + Widget buildChild( + BuildContext context, + DataSource source, + DynamicContent data, + RemoteEventHandler remoteEventTarget, + List<_WidgetState> states, + _StateResolverCallback stateResolver, + _DataResolverCallback dataResolver, + _WidgetBuilderArgResolverCallback widgetBuilderArgResolver, + ) { return child.build(context, data, remoteEventTarget, states); } @@ -808,13 +1093,34 @@ class _CurriedRemoteWidget extends _CurriedWidget { } class _CurriedSwitch extends _CurriedWidget { - const _CurriedSwitch(FullyQualifiedWidgetName fullName, this.root, DynamicMap arguments, DynamicMap? initialState) : super(fullName, arguments, initialState); + const _CurriedSwitch( + FullyQualifiedWidgetName fullName, + this.root, + DynamicMap arguments, + DynamicMap widgetBuilderScope, + DynamicMap? initialState, + ) : super(fullName, arguments, widgetBuilderScope, initialState); final Switch root; @override - Widget buildChild(BuildContext context, DataSource source, DynamicContent data, RemoteEventHandler remoteEventTarget, List<_WidgetState> states, _StateResolverCallback stateResolver, _DataResolverCallback dataResolver) { - final Object resolvedWidget = _CurriedWidget._resolveFrom(root, const [], stateResolver, dataResolver); + Widget buildChild( + BuildContext context, + DataSource source, + DynamicContent data, + RemoteEventHandler remoteEventTarget, + List<_WidgetState> states, + _StateResolverCallback stateResolver, + _DataResolverCallback dataResolver, + _WidgetBuilderArgResolverCallback widgetBuilderArgResolver, + ) { + final Object resolvedWidget = _CurriedWidget._resolveFrom( + root, + const [], + stateResolver, + dataResolver, + widgetBuilderArgResolver, + ); if (resolvedWidget is _CurriedWidget) { return resolvedWidget.build(context, data, remoteEventTarget, states); } @@ -826,12 +1132,20 @@ class _CurriedSwitch extends _CurriedWidget { } class _Widget extends StatefulWidget { - const _Widget({ required this.curriedWidget, required this.data, required this.remoteEventTarget, required this.states }); + const _Widget({ + required this.curriedWidget, + required this.data, + required this.widgetBuilderScope, + required this.remoteEventTarget, + required this.states, + }); final _CurriedWidget curriedWidget; final DynamicContent data; + final DynamicContent widgetBuilderScope; + final RemoteEventHandler remoteEventTarget; final List<_WidgetState> states; @@ -1014,6 +1328,36 @@ class _WidgetState extends State<_Widget> implements DataSource { ]; } + @override + Widget builder(List argsKey, DynamicMap builderArg) { + return _fetchBuilder(argsKey, builderArg, optional: false)!; + } + + @override + Widget? optionalBuilder(List argsKey, DynamicMap builderArg) { + return _fetchBuilder(argsKey, builderArg); + } + + Widget? _fetchBuilder( + List argsKey, + DynamicMap builderArg, { + bool optional = true, + }) { + final Object value = _fetch(argsKey, expandLists: false); + if (value is _RemoteWidgetBuilder) { + final _CurriedWidget curriedWidget = value(builderArg); + return curriedWidget.build( + context, + widget.data, + widget.remoteEventTarget, + widget.states, + ); + } + return optional + ? null + : _buildErrorWidget('Not a builder at $argsKey (got $value) for ${widget.curriedWidget.fullName}.'); + } + @override VoidCallback? voidHandler(List argsKey, [ DynamicMap? extraArguments ]) { return handler(argsKey, (HandlerTrigger callback) => () => callback(extraArguments)); @@ -1064,7 +1408,13 @@ class _WidgetState extends State<_Widget> implements DataSource { assert(!_debugFetching); try { _debugFetching = true; - final Object result = widget.curriedWidget.resolve(argsKey, _stateResolver, _dataResolver, expandLists: expandLists); + final Object result = widget.curriedWidget.resolve( + argsKey, + _stateResolver, + _dataResolver, + _widgetBuilderArgResolver, + expandLists: expandLists, + ); for (final _Subscription subscription in _dependencies) { subscription.addClient(key); } @@ -1095,6 +1445,17 @@ class _WidgetState extends State<_Widget> implements DataSource { return subscription.value; } + Object _widgetBuilderArgResolver(List rawDataKey) { + final _Key widgetBuilderArgKey = _Key(_kWidgetBuilderArgSection, rawDataKey); + final _Subscription subscription = _subscriptions[widgetBuilderArgKey] ??= _Subscription( + widget.widgetBuilderScope, + this, + rawDataKey, + ); + _dependencies.add(subscription); + return subscription.value; + } + Object _stateResolver(List rawStateKey, int depth) { final _Key stateKey = _Key(depth, rawStateKey); final _Subscription subscription; @@ -1126,7 +1487,16 @@ class _WidgetState extends State<_Widget> implements DataSource { @override Widget build(BuildContext context) { // TODO(ianh): what if this creates some _dependencies? - return widget.curriedWidget.buildChild(context, this, widget.data, widget.remoteEventTarget, _states, _stateResolver, _dataResolver); + return widget.curriedWidget.buildChild( + context, + this, + widget.data, + widget.remoteEventTarget, + _states, + _stateResolver, + _dataResolver, + _widgetBuilderArgResolver, + ); } @override @@ -1138,6 +1508,7 @@ class _WidgetState extends State<_Widget> implements DataSource { const int _kDataSection = -1; const int _kArgsSection = -2; +const int _kWidgetBuilderArgSection = -3; @immutable class _Key { diff --git a/packages/rfw/pubspec.yaml b/packages/rfw/pubspec.yaml index 6056af37679b..310d7bd6535c 100644 --- a/packages/rfw/pubspec.yaml +++ b/packages/rfw/pubspec.yaml @@ -2,7 +2,7 @@ name: rfw description: "Remote Flutter widgets: a library for rendering declarative widget description files at runtime." repository: https://github.com/flutter/packages/tree/main/packages/rfw issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+rfw%22 -version: 1.0.24 +version: 1.0.25 environment: sdk: ^3.2.0 diff --git a/packages/rfw/test/binary_test.dart b/packages/rfw/test/binary_test.dart index 0eca7888bbe9..916f92bf15e5 100644 --- a/packages/rfw/test/binary_test.dart +++ b/packages/rfw/test/binary_test.dart @@ -503,4 +503,41 @@ void main() { expect((value.widgets.first.root as ConstructorCall).name, 'c'); expect((value.widgets.first.root as ConstructorCall).arguments, isEmpty); }); + + testWidgets('Library encoder: widget builders work', (WidgetTester tester) async { + const String source = ''' + widget Foo = Builder( + builder: (scope) => Text(text: scope.text), + ); + '''; + final RemoteWidgetLibrary library = parseLibraryFile(source); + final Uint8List encoded = encodeLibraryBlob(library); + final RemoteWidgetLibrary decoded = decodeLibraryBlob(encoded); + + expect(library.toString(), decoded.toString()); + }); + + testWidgets('Library encoder: widget builders throws', (WidgetTester tester) async { + const RemoteWidgetLibrary remoteWidgetLibrary = RemoteWidgetLibrary( + [], + [ + WidgetDeclaration( + 'a', + {}, + ConstructorCall( + 'c', + { + 'builder': WidgetBuilderDeclaration('scope', ArgsReference([])), + }, + ), + ), + ], + ); + try { + decodeLibraryBlob(encodeLibraryBlob(remoteWidgetLibrary)); + fail('did not throw exception'); + } on FormatException catch (e) { + expect('$e', contains('Unrecognized data type 0x0A while decoding widget builder blob.')); + } + }); } diff --git a/packages/rfw/test/runtime_test.dart b/packages/rfw/test/runtime_test.dart index 89cb96d62d51..f081df3e8126 100644 --- a/packages/rfw/test/runtime_test.dart +++ b/packages/rfw/test/runtime_test.dart @@ -1088,4 +1088,470 @@ void main() { data.update('c', 'test'); expect(log, ['leaf: 2', 'root: {a: [2, 3], b: [q, r]}', 'root: {a: [2, 3], b: [q, r], c: test}']); }); + + testWidgets('Data source - optional builder works', (WidgetTester tester) async { + const LibraryName coreLibraryName = LibraryName(['core']); + const LibraryName localLibraryName = LibraryName(['local']); + const LibraryName remoteLibraryName = LibraryName(['remote']); + final Runtime runtime = Runtime(); + final DynamicContent data = DynamicContent(); + runtime.update(coreLibraryName, createCoreWidgets()); + runtime.update(localLibraryName, LocalWidgetLibrary( { + 'Builder': (BuildContext context, DataSource source) { + final Widget? builder = source.optionalBuilder(['builder'], {}); + return builder ?? const Text('Hello World!', textDirection: TextDirection.ltr); + }, + })); + runtime.update(remoteLibraryName, parseLibraryFile(''' + import core; + import local; + + widget test = Builder( + builder: Text(text: 'Not a builder :/'), + ); + ''')); + await tester.pumpWidget(RemoteWidget( + runtime: runtime, + data: data, + widget: const FullyQualifiedWidgetName(remoteLibraryName, 'test'), + )); + + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + expect(tester.widget(textFinder).data, 'Hello World!'); + }); + + testWidgets('Data source - builder returns an error widget', (WidgetTester tester) async { + const LibraryName coreLibraryName = LibraryName(['core']); + const LibraryName localLibraryName = LibraryName(['local']); + const LibraryName remoteLibraryName = LibraryName(['remote']); + final Runtime runtime = Runtime(); + final DynamicContent data = DynamicContent(); + const String expectedErrorMessage = 'Not a builder at [builder] (got core:Text {} {text: Not a builder :/}) for local:Builder.'; + + runtime.update(coreLibraryName, createCoreWidgets()); + runtime.update(localLibraryName, LocalWidgetLibrary( { + 'Builder': (BuildContext context, DataSource source) { + return source.builder(['builder'], {}); + }, + })); + runtime.update(remoteLibraryName, parseLibraryFile(''' + import core; + import local; + + widget test = Builder( + builder: Text(text: 'Not a builder :/'), + ); + ''')); + await tester.pumpWidget(RemoteWidget( + runtime: runtime, + data: data, + widget: const FullyQualifiedWidgetName(remoteLibraryName, 'test'), + )); + + expect(tester.takeException().toString(), contains(expectedErrorMessage)); + expect(find.byType(ErrorWidget), findsOneWidget); + expect(tester.widget(find.byType(ErrorWidget)).message, expectedErrorMessage); + }); + + testWidgets('Widget builders - work when scope is not used', (WidgetTester tester) async { + const LibraryName coreLibraryName = LibraryName(['core']); + const LibraryName localLibraryName = LibraryName(['local']); + const LibraryName remoteLibraryName = LibraryName(['remote']); + final Runtime runtime = Runtime(); + final DynamicContent data = DynamicContent(); + final Finder textFinder = find.byType(Text); + + runtime.update(coreLibraryName, createCoreWidgets()); + runtime.update(localLibraryName, LocalWidgetLibrary( { + 'Builder': (BuildContext context, DataSource source) { + return source.builder(['builder'], {}); + }, + })); + runtime.update(remoteLibraryName, parseLibraryFile(''' + import core; + import local; + + widget test = Builder( + builder: (scope) => Text(text: 'Hello World!', textDirection: 'ltr'), + ); + ''')); + await tester.pumpWidget(RemoteWidget( + runtime: runtime, + data: data, + widget: const FullyQualifiedWidgetName(remoteLibraryName, 'test'), + )); + + expect(textFinder, findsOneWidget); + expect(tester.widget(textFinder).data, 'Hello World!'); + }); + + testWidgets('Widget builders - work when scope is used', (WidgetTester tester) async { + const LibraryName coreLibraryName = LibraryName(['core']); + const LibraryName localLibraryName = LibraryName(['local']); + const LibraryName remoteLibraryName = LibraryName(['remote']); + final Runtime runtime = Runtime(); + final DynamicContent data = DynamicContent(); + final Finder textFinder = find.byType(Text); + + runtime.update(coreLibraryName, createCoreWidgets()); + runtime.update(localLibraryName, LocalWidgetLibrary( { + 'HelloWorld': (BuildContext context, DataSource source) { + const String result = 'Hello World!'; + return source.builder(['builder'], {'result': result}); + }, + })); + runtime.update(remoteLibraryName, parseLibraryFile(''' + import core; + import local; + + widget test = HelloWorld( + builder: (result) => Text(text: result.result, textDirection: 'ltr'), + ); + ''')); + await tester.pumpWidget(RemoteWidget( + runtime: runtime, + data: data, + widget: const FullyQualifiedWidgetName(remoteLibraryName, 'test'), + )); + + expect(textFinder, findsOneWidget); + expect(tester.widget(textFinder).data, 'Hello World!'); + }); + + testWidgets('Widget builders - work with state', (WidgetTester tester) async { + const LibraryName coreLibraryName = LibraryName(['core']); + const LibraryName localLibraryName = LibraryName(['local']); + const LibraryName remoteLibraryName = LibraryName(['remote']); + final Runtime runtime = Runtime(); + final DynamicContent data = DynamicContent(); + final Finder textFinder = find.byType(Text); + + runtime.update(coreLibraryName, createCoreWidgets()); + runtime.update(localLibraryName, LocalWidgetLibrary( { + 'IntToString': (BuildContext context, DataSource source) { + final int value = source.v(['value'])!; + final String result = value.toString(); + return source.builder(['builder'], {'result': result}); + }, + })); + runtime.update(remoteLibraryName, parseLibraryFile(''' + import core; + import local; + + widget test {value: 0} = IntToString( + value: state.value, + builder: (result) => Text(text: result.result, textDirection: 'ltr'), + ); + ''')); + await tester.pumpWidget(RemoteWidget( + runtime: runtime, + data: data, + widget: const FullyQualifiedWidgetName(remoteLibraryName, 'test'), + )); + + expect(textFinder, findsOneWidget); + expect(tester.widget(textFinder).data, '0'); + }); + + + testWidgets('Widget builders - work with data', (WidgetTester tester) async { + const LibraryName coreLibraryName = LibraryName(['core']); + const LibraryName localLibraryName = LibraryName(['local']); + const LibraryName remoteLibraryName = LibraryName(['remote']); + final Runtime runtime = Runtime(); + final DynamicContent data = DynamicContent({'value': 0}); + final Finder textFinder = find.byType(Text); + + runtime.update(coreLibraryName, createCoreWidgets()); + runtime.update(localLibraryName, LocalWidgetLibrary( { + 'IntToString': (BuildContext context, DataSource source) { + final int value = source.v(['value'])!; + final String result = value.toString(); + return source.builder(['builder'], {'result': result}); + }, + })); + runtime.update(remoteLibraryName, parseLibraryFile(''' + import core; + import local; + + widget test = IntToString( + value: data.value, + builder: (result) => Text(text: result.result, textDirection: 'ltr'), + ); + ''')); + await tester.pumpWidget(RemoteWidget( + runtime: runtime, + data: data, + widget: const FullyQualifiedWidgetName(remoteLibraryName, 'test'), + )); + + expect(textFinder, findsOneWidget); + expect(tester.widget(textFinder).data, '0'); + + data.update('value', 1); + await tester.pump(); + expect(tester.widget(textFinder).data, '1'); + }); + + testWidgets('Widget builders - work with events', (WidgetTester tester) async { + const LibraryName coreLibraryName = LibraryName(['core']); + const LibraryName localLibraryName = LibraryName(['local']); + const LibraryName remoteLibraryName = LibraryName(['remote']); + final Runtime runtime = Runtime(); + final DynamicContent data = DynamicContent(); + final List dispatchedEvents = []; + final Finder textFinder = find.byType(Text); + + runtime.update(coreLibraryName, createCoreWidgets()); + runtime.update(localLibraryName, LocalWidgetLibrary( { + 'Zero': (BuildContext context, DataSource source) { + return source.builder(['builder'], {'result': 0}); + }, + })); + runtime.update(remoteLibraryName, parseLibraryFile(''' + import core; + import local; + + widget test = Zero( + builder: (result) => GestureDetector( + onTap: event 'works' {number: result.result}, + child: Text(text: 'Tap to trigger an event.', textDirection: 'ltr'), + ), + ); + ''')); + await tester.pumpWidget(RemoteWidget( + runtime: runtime, + data: data, + widget: const FullyQualifiedWidgetName(remoteLibraryName, 'test'), + onEvent: (String eventName, DynamicMap eventArguments) => + dispatchedEvents.add(RfwEvent(eventName, eventArguments)), + )); + + await tester.tap(textFinder); + await tester.pump(); + expect(dispatchedEvents, hasLength(1)); + expect(dispatchedEvents.single.name, 'works'); + expect(dispatchedEvents.single.arguments['number'], 0); + }); + + testWidgets('Widget builders - works nested', (WidgetTester tester) async { + const LibraryName coreLibraryName = LibraryName(['core']); + const LibraryName localLibraryName = LibraryName(['local']); + const LibraryName remoteLibraryName = LibraryName(['remote']); + final Runtime runtime = Runtime(); + final DynamicContent data = DynamicContent(); + final Finder textFinder = find.byType(Text); + runtime.update(coreLibraryName, createCoreWidgets()); + runtime.update(localLibraryName, LocalWidgetLibrary( { + 'Sum': (BuildContext context, DataSource source) { + final int operand1 = source.v(['operand1'])!; + final int operand2 = source.v(['operand2'])!; + final int result = operand1 + operand2; + return source.builder(['builder'], {'result': result}); + }, + 'IntToString': (BuildContext context, DataSource source) { + final int value = source.v(['value'])!; + final String result = value.toString(); + return source.builder(['builder'], {'result': result}); + }, + })); + runtime.update(remoteLibraryName, parseLibraryFile(''' + import core; + import local; + + widget test = Sum( + operand1: 1, + operand2: 2, + builder: (result1) => IntToString( + value: result1.result, + builder: (result2) => Text(text: ['1 + 2 = ', result2.result], textDirection: 'ltr'), + ), + ); + ''')); + await tester.pumpWidget(RemoteWidget( + runtime: runtime, + data: data, + widget: const FullyQualifiedWidgetName(remoteLibraryName, 'test'), + )); + + expect(textFinder, findsOneWidget); + expect(tester.widget(textFinder).data, '1 + 2 = 3'); + }); + + testWidgets('Widget builders - works nested dynamically', (WidgetTester tester) async { + const LibraryName coreLibraryName = LibraryName(['core']); + const LibraryName localLibraryName = LibraryName(['local']); + const LibraryName remoteLibraryName = LibraryName(['remote']); + final Map handlers = {}; + final Runtime runtime = Runtime(); + final DynamicContent data = DynamicContent({ + 'a1': 'apricot', + 'b1': 'blueberry', + }); + final Finder textFinder = find.byType(Text); + + runtime.update(coreLibraryName, createCoreWidgets()); + runtime.update(localLibraryName, LocalWidgetLibrary( { + 'Builder': (BuildContext context, DataSource source) { + final String? id = source.v(['id']); + if (id != null) { + handlers[id] = source.voidHandler(['handler'])!; + } + return source.builder(['builder'], { + 'param1': source.v(['arg1']), + 'param2': source.v(['arg2']), + }); + }, + })); + runtime.update(remoteLibraryName, parseLibraryFile(''' + import core; + import local; + + widget test { state1: 'strawberry' } = Builder( + arg1: data.a1, + arg2: 'apple', + id: 'A', + handler: set state.state1 = 'STRAWBERRY', + builder: (builder1) => Builder( + arg1: data.b1, + arg2: 'banana', + builder: (builder2) => Text( + textDirection: 'ltr', + text: [ + state.state1, ' ', builder1.param1, ' ', builder1.param2, ' ', builder2.param1, ' ', builder2.param2, + ], + ), + ), + ); + ''')); + await tester.pumpWidget(RemoteWidget( + runtime: runtime, + data: data, + widget: const FullyQualifiedWidgetName(remoteLibraryName, 'test'), + )); + + expect(tester.widget(textFinder).data, 'strawberry apricot apple blueberry banana'); + + data.update('a1', 'APRICOT'); + await tester.pump(); + expect(tester.widget(textFinder).data, 'strawberry APRICOT apple blueberry banana'); + + data.update('b1', 'BLUEBERRY'); + await tester.pump(); + expect(tester.widget(textFinder).data, 'strawberry APRICOT apple BLUEBERRY banana'); + + handlers['A']!(); + await tester.pump(); + expect(tester.widget(textFinder).data, 'STRAWBERRY APRICOT apple BLUEBERRY banana'); + }); + + testWidgets('Widget builders - switch works with builder', (WidgetTester tester) async { + const LibraryName coreLibraryName = LibraryName(['core']); + const LibraryName localLibraryName = LibraryName(['local']); + const LibraryName remoteLibraryName = LibraryName(['remote']); + final Runtime runtime = Runtime(); + final DynamicContent data = DynamicContent(); + final Finder textFinder = find.byType(Text); + + runtime.update(coreLibraryName, createCoreWidgets()); + runtime.update(localLibraryName, LocalWidgetLibrary( { + 'Builder': (BuildContext context, DataSource source) { + return source.builder(['builder'], {}); + }, + })); + runtime.update(remoteLibraryName, parseLibraryFile(''' + import core; + import local; + + widget test {enabled: false} = Builder( + value: state.value, + builder: switch state.enabled { + true: (scope) => GestureDetector( + onTap: set state.enabled = false, + child: Text(text: 'The builder is enabled.', textDirection: 'ltr'), + ), + false: (scope) => GestureDetector( + onTap: set state.enabled = true, + child: Text(text: 'The builder is disabled.', textDirection: 'ltr'), + ), + }, + ); + ''')); + await tester.pumpWidget(RemoteWidget( + runtime: runtime, + data: data, + widget: const FullyQualifiedWidgetName(remoteLibraryName, 'test'), + )); + + + expect(textFinder, findsOneWidget); + expect(tester.widget(textFinder).data, 'The builder is disabled.'); + + await tester.tap(textFinder); + await tester.pump(); + expect(textFinder, findsOneWidget); + expect(tester.widget(textFinder).data, 'The builder is enabled.'); + }); + + testWidgets('Widget builders - builder works with switch', (WidgetTester tester) async { + const LibraryName coreLibraryName = LibraryName(['core']); + const LibraryName localLibraryName = LibraryName(['local']); + const LibraryName remoteLibraryName = LibraryName(['remote']); + final Runtime runtime = Runtime(); + final DynamicContent data = DynamicContent(); + final Finder textFinder = find.byType(Text); + runtime.update(coreLibraryName, createCoreWidgets()); + runtime.update(localLibraryName, LocalWidgetLibrary( { + 'Inverter': (BuildContext context, DataSource source) { + final bool value = source.v(['value'])!; + return source.builder(['builder'], {'result': !value}); + }, + })); + runtime.update(remoteLibraryName, parseLibraryFile(''' + import core; + import local; + + widget test {value: false} = Inverter( + value: state.value, + builder: (result) => switch result.result { + true: GestureDetector( + onTap: set state.value = switch state.value { + true: false, + false: true, + }, + child: Text(text: 'The input is false, the output is true', textDirection: 'ltr'), + ), + false: GestureDetector( + onTap: set state.value = switch state.value { + true: false, + false: true, + }, + child: Text(text: 'The input is true, the output is false', textDirection: 'ltr'), + ), + }, + ); + ''')); + await tester.pumpWidget(RemoteWidget( + runtime: runtime, + data: data, + widget: const FullyQualifiedWidgetName(remoteLibraryName, 'test'), + )); + + expect(textFinder, findsOneWidget); + expect(tester.widget(textFinder).data, 'The input is false, the output is true'); + + await tester.tap(textFinder); + await tester.pump(); + expect(textFinder, findsOneWidget); + expect(tester.widget(textFinder).data, 'The input is true, the output is false'); + }); +} + +final class RfwEvent { + RfwEvent(this.name, this.arguments); + + final String name; + final DynamicMap arguments; } diff --git a/packages/rfw/test/text_test.dart b/packages/rfw/test/text_test.dart index 8fe51d75a3d9..2dac169fb874 100644 --- a/packages/rfw/test/text_test.dart +++ b/packages/rfw/test/text_test.dart @@ -340,4 +340,145 @@ void main() { final RemoteWidgetLibrary result = parseLibraryFile('widget a {b: 0} = c();'); expect(result.widgets.single.initialState, {'b': 0}); }); + + testWidgets('parseLibraryFile: widgetBuilders work', (WidgetTester tester) async { + final RemoteWidgetLibrary libraryFile = parseLibraryFile(''' + widget a = Builder(builder: (scope) => Container()); + '''); + expect(libraryFile.toString(), 'widget a = Builder({builder: (scope) => Container({})});'); + }); + + testWidgets('parseLibraryFile: widgetBuilders work with arguments', (WidgetTester tester) async { + final RemoteWidgetLibrary libraryFile = parseLibraryFile(''' + widget a = Builder(builder: (scope) => Container(width: scope.width)); + '''); + expect(libraryFile.toString(), 'widget a = Builder({builder: (scope) => Container({width: scope.width})});'); + }); + + testWidgets('parseLibraryFile: widgetBuilder arguments are lexical scoped', (WidgetTester tester) async { + final RemoteWidgetLibrary libraryFile = parseLibraryFile(''' + widget a = A( + a: (s1) => B( + b: (s2) => T(s1: s1.s1, s2: s2.s2), + ), + ); + '''); + expect(libraryFile.toString(), 'widget a = A({a: (s1) => B({b: (s2) => T({s1: s1.s1, s2: s2.s2})})});'); + }); + + testWidgets('parseLibraryFile: widgetBuilder arguments can be shadowed', (WidgetTester tester) async { + final RemoteWidgetLibrary libraryFile = parseLibraryFile(''' + widget a = A( + a: (s1) => B( + b: (s1) => T(t: s1.foo), + ), + ); + '''); + expect(libraryFile.toString(), 'widget a = A({a: (s1) => B({b: (s1) => T({t: s1.foo})})});'); + }); + + testWidgets('parseLibraryFile: widgetBuilders check the returned value', (WidgetTester tester) async { + void test(String input, String expectedMessage) { + try { + parseLibraryFile(input); + fail('parsing `$input` did not result in an error (expected "$expectedMessage").'); + } on ParserException catch (e) { + expect('$e', expectedMessage); + } + } + + const String expectedErrorMessage = + 'Expecting a switch or constructor call got 1 at line 1 column 27.'; + test('widget a = B(b: (foo) => 1);', expectedErrorMessage); + }); + + testWidgets('parseLibraryFile: widgetBuilders check reserved words', (WidgetTester tester) async { + void test(String input, String expectedMessage) { + try { + parseLibraryFile(input); + fail('parsing `$input` did not result in an error (expected "$expectedMessage").'); + } on ParserException catch (e) { + expect('$e', expectedMessage); + } + } + + const String expectedErrorMessage = + 'args is a reserved word at line 1 column 34.'; + test('widget a = Builder(builder: (args) => Container(width: args.width));', expectedErrorMessage); + }); + + testWidgets('parseLibraryFile: widgetBuilders check reserved words', (WidgetTester tester) async { + void test(String input, String expectedMessage) { + try { + parseDataFile(input); + fail('parsing `$input` did not result in an error (expected "$expectedMessage").'); + } on ParserException catch (e) { + expect('$e', expectedMessage); + } + } + + const String expectedErrorMessage = + 'Expected symbol "{" but found widget at line 1 column 7.'; + test('widget a = Builder(builder: (args) => Container(width: args.width));', expectedErrorMessage); + }); + + testWidgets('parseLibraryFile: switch works with widgetBuilders', (WidgetTester tester) async { + final RemoteWidgetLibrary libraryFile = parseLibraryFile(''' + widget a = A( + b: switch args.down { + true: (foo) => B(), + false: (bar) => C(), + } + ); + '''); + expect(libraryFile.toString(), 'widget a = A({b: switch args.down {true: (foo) => B({}), false: (bar) => C({})}});'); + }); + + testWidgets('parseLibraryFile: widgetBuilders work with switch', (WidgetTester tester) async { + final RemoteWidgetLibrary libraryFile = parseLibraryFile(''' + widget a = A( + b: (foo) => switch foo.letter { + 'a': A(), + 'b': B(), + }, + ); + '''); + expect(libraryFile.toString(), 'widget a = A({b: (foo) => switch foo.letter {a: A({}), b: B({})}});'); + }); + + testWidgets('parseLibraryFile: widgetBuilders work with lists', (WidgetTester tester) async { + final RemoteWidgetLibrary libraryFile = parseLibraryFile(''' + widget a = A( + b: (s1) => B(c: [s1.c]), + ); + '''); + expect(libraryFile.toString(), 'widget a = A({b: (s1) => B({c: [s1.c]})});' ); + }); + + testWidgets('parseLibraryFile: widgetBuilders work with maps', (WidgetTester tester) async { + final RemoteWidgetLibrary libraryFile = parseLibraryFile(''' + widget a = A( + b: (s1) => B(c: {d: s1.d}), + ); + '''); + expect(libraryFile.toString(), 'widget a = A({b: (s1) => B({c: {d: s1.d}})});'); + }); + + testWidgets('parseLibraryFile: widgetBuilders work with setters', (WidgetTester tester) async { + final RemoteWidgetLibrary libraryFile = parseLibraryFile(''' + widget a {foo: 0} = A( + b: (s1) => B(onTap: set state.foo = s1.foo), + ); + '''); + expect(libraryFile.toString(), 'widget a = A({b: (s1) => B({onTap: set state.foo = s1.foo})});'); + }); + + testWidgets('parseLibraryFile: widgetBuilders work with events', (WidgetTester tester) async { + final RemoteWidgetLibrary libraryFile = parseLibraryFile(''' + widget a {foo: 0} = A( + b: (s1) => B(onTap: event "foo" {result: s1.result}) + ); + '''); + expect(libraryFile.toString(), 'widget a = A({b: (s1) => B({onTap: event foo {result: s1.result}})});'); + }); }