From e86662953a0b35e123bfa5de2e74044c8844ea2e Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Thu, 30 Oct 2025 12:32:51 -0700 Subject: [PATCH 1/5] Add primary button support and support a more permissive data model --- .../catalog/checkbox_filter_chips_input.dart | 2 +- .../lib/src/travel_planner_page.dart | 3 + .../lib/src/catalog/core_widgets/button.dart | 84 ++++++++- .../lib/src/catalog/core_widgets/image.dart | 6 +- .../lib/src/catalog/core_widgets/list.dart | 25 +-- .../catalog/core_widgets/multiple_choice.dart | 2 +- .../catalog/core_widgets/widget_helpers.dart | 56 +++--- .../src/conversation/gen_ui_conversation.dart | 6 +- .../lib/src/core/genui_manager.dart | 45 +++-- .../lib/src/core/genui_surface.dart | 9 +- .../lib/src/core/widget_utilities.dart | 10 +- .../lib/src/model/data_model.dart | 159 ++++++++++++++---- .../lib/src/model/ui_models.dart | 9 +- .../test/core/genui_manager_test.dart | 4 +- .../test/model/data_model_test.dart | 130 ++++++++++++++ .../lib/src/a2ui_agent_connector.dart | 26 ++- .../flutter_genui_a2ui/server_to_client.json | 79 ++++++--- .../lib/src/core/a2ui_agent_connector.dart | 4 +- 18 files changed, 515 insertions(+), 144 deletions(-) rename server_to_client.json => packages/flutter_genui_a2ui/server_to_client.json (89%) diff --git a/examples/travel_app/lib/src/catalog/checkbox_filter_chips_input.dart b/examples/travel_app/lib/src/catalog/checkbox_filter_chips_input.dart index 36de3ffcd..72fce583a 100644 --- a/examples/travel_app/lib/src/catalog/checkbox_filter_chips_input.dart +++ b/examples/travel_app/lib/src/catalog/checkbox_filter_chips_input.dart @@ -130,7 +130,7 @@ final checkboxFilterChipsInput = CatalogItem( } final selectedOptionsRef = checkboxFilterChipsData.selectedOptions; - final notifier = dataContext.subscribeToStringArray(selectedOptionsRef); + final notifier = dataContext.subscribeToObjectArray(selectedOptionsRef); return ValueListenableBuilder?>( valueListenable: notifier, diff --git a/examples/travel_app/lib/src/travel_planner_page.dart b/examples/travel_app/lib/src/travel_planner_page.dart index 1476de6f1..615562a01 100644 --- a/examples/travel_app/lib/src/travel_planner_page.dart +++ b/examples/travel_app/lib/src/travel_planner_page.dart @@ -82,6 +82,9 @@ class _TravelPlannerPageState extends State _uiConversation = GenUiConversation( genUiManager: genUiManager, contentGenerator: contentGenerator, + onSurfaceUpdated: (update) { + _scrollToBottom(); + }, onSurfaceAdded: (update) { _scrollToBottom(); }, diff --git a/packages/flutter_genui/lib/src/catalog/core_widgets/button.dart b/packages/flutter_genui/lib/src/catalog/core_widgets/button.dart index b1c463c72..e7091a1b1 100644 --- a/packages/flutter_genui/lib/src/catalog/core_widgets/button.dart +++ b/packages/flutter_genui/lib/src/catalog/core_widgets/button.dart @@ -22,16 +22,27 @@ final _schema = S.object( 'of a `Text` widget.', ), 'action': A2uiSchemas.action(), + 'primary': S.boolean( + description: 'Whether the button invokes a primary action.', + ), }, required: ['child', 'action'], ); extension type _ButtonData.fromMap(JsonMap _json) { - factory _ButtonData({required String child, required JsonMap action}) => - _ButtonData.fromMap({'child': child, 'action': action}); + factory _ButtonData({ + required String child, + required JsonMap action, + bool primary = false, + }) => _ButtonData.fromMap({ + 'child': child, + 'action': action, + 'primary': primary, + }); String get child => _json['child'] as String; JsonMap get action => _json['action'] as JsonMap; + bool get primary => (_json['primary'] as bool?) ?? false; } /// A catalog item for a Material Design elevated button. @@ -42,6 +53,8 @@ extension type _ButtonData.fromMap(JsonMap _json) { /// /// - `child`: The ID of a child widget to display inside the button. /// - `action`: The action to perform when the button is pressed. +/// - `primary`: Whether the button invokes a primary action (defaults to +/// false). final button = CatalogItem( name: 'Button', dataSchema: _schema, @@ -62,8 +75,18 @@ final button = CatalogItem( (actionData['context'] as List?) ?? []; genUiLogger.info('Building Button with child: ${buttonData.child}'); + final colorScheme = Theme.of(context).colorScheme; + final primary = buttonData.primary; return ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: primary + ? colorScheme.primary + : colorScheme.surface, + foregroundColor: primary + ? colorScheme.onPrimary + : colorScheme.onSurface, + ), onPressed: () { final resolvedContext = resolveContext( dataContext, @@ -106,5 +129,62 @@ final button = CatalogItem( } ] ''', + () => ''' + [ + { + "id": "root", + "component": { + "Column": { + "children": { + "explicitList": ["primaryButton", "secondaryButton"] + } + } + } + }, + { + "id": "primaryButton", + "component": { + "Button": { + "child": "primaryText", + "primary": true, + "action": { + "name": "primary_pressed" + } + } + } + }, + { + "id": "secondaryButton", + "component": { + "Button": { + "child": "secondaryText", + "action": { + "name": "secondary_pressed" + } + } + } + }, + { + "id": "primaryText", + "component": { + "Text": { + "text": { + "literalString": "Primary Button" + } + } + } + }, + { + "id": "secondaryText", + "component": { + "Text": { + "text": { + "literalString": "Secondary Button" + } + } + } + } + ] + ''', ], ); diff --git a/packages/flutter_genui/lib/src/catalog/core_widgets/image.dart b/packages/flutter_genui/lib/src/catalog/core_widgets/image.dart index 7fcc96fa4..9db57f8b4 100644 --- a/packages/flutter_genui/lib/src/catalog/core_widgets/image.dart +++ b/packages/flutter_genui/lib/src/catalog/core_widgets/image.dart @@ -8,6 +8,7 @@ import 'package:json_schema_builder/json_schema_builder.dart'; import '../../core/widget_utilities.dart'; import '../../model/a2ui_schemas.dart'; import '../../model/catalog_item.dart'; +import '../../primitives/logging.dart'; import '../../primitives/simple_items.dart'; final _schema = S.object( @@ -76,7 +77,10 @@ final image = CatalogItem( valueListenable: notifier, builder: (context, currentLocation, child) { final location = currentLocation; - if (location == null) { + if (location == null || location.isEmpty) { + genUiLogger.warning( + 'Image widget created with no URL at path: ${dataContext.path}', + ); return const SizedBox.shrink(); } final fit = imageData.fit; diff --git a/packages/flutter_genui/lib/src/catalog/core_widgets/list.dart b/packages/flutter_genui/lib/src/catalog/core_widgets/list.dart index 969ed1580..8c7e0095b 100644 --- a/packages/flutter_genui/lib/src/catalog/core_widgets/list.dart +++ b/packages/flutter_genui/lib/src/catalog/core_widgets/list.dart @@ -72,19 +72,22 @@ final list = CatalogItem( children: children, ); }, - templateListWidgetBuilder: (context, list, componentId, dataBinding) { - return ListView.builder( - shrinkWrap: true, - scrollDirection: direction, - itemCount: list.length, - itemBuilder: (context, index) { - final itemDataContext = dataContext.nested( - DataPath('$dataBinding[$index]'), + templateListWidgetBuilder: + (context, Map data, componentId, dataBinding) { + final values = data.values.toList(); + final keys = data.keys.toList(); + return ListView.builder( + shrinkWrap: true, + scrollDirection: direction, + itemCount: values.length, + itemBuilder: (context, index) { + final itemDataContext = dataContext.nested( + DataPath('$dataBinding/${keys[index]}'), + ); + return buildChild(componentId, itemDataContext); + }, ); - return buildChild(componentId, itemDataContext); }, - ); - }, ); }, exampleData: [ diff --git a/packages/flutter_genui/lib/src/catalog/core_widgets/multiple_choice.dart b/packages/flutter_genui/lib/src/catalog/core_widgets/multiple_choice.dart index c083cae7d..4e1a28dcd 100644 --- a/packages/flutter_genui/lib/src/catalog/core_widgets/multiple_choice.dart +++ b/packages/flutter_genui/lib/src/catalog/core_widgets/multiple_choice.dart @@ -68,7 +68,7 @@ final multipleChoice = CatalogItem( required dataContext, }) { final multipleChoiceData = _MultipleChoiceData.fromMap(data as JsonMap); - final selectionsNotifier = dataContext.subscribeToStringArray( + final selectionsNotifier = dataContext.subscribeToObjectArray( multipleChoiceData.selections, ); diff --git a/packages/flutter_genui/lib/src/catalog/core_widgets/widget_helpers.dart b/packages/flutter_genui/lib/src/catalog/core_widgets/widget_helpers.dart index 4844f82c5..8907884c0 100644 --- a/packages/flutter_genui/lib/src/catalog/core_widgets/widget_helpers.dart +++ b/packages/flutter_genui/lib/src/catalog/core_widgets/widget_helpers.dart @@ -9,6 +9,16 @@ import '../../model/data_model.dart'; import '../../primitives/logging.dart'; import '../../primitives/simple_items.dart'; +typedef TemplateListWidgetBuilder = + Widget Function( + BuildContext context, + Map data, + String componentId, + String dataBinding, + ); + +typedef ExplicitListWidgetBuilder = Widget Function(List children); + /// A helper widget to build widgets from component data that contains a list /// of children. /// @@ -41,23 +51,13 @@ class ComponentChildrenBuilder extends StatelessWidget { final ChildBuilderCallback buildChild; /// The builder for an explicit list of children. - final Widget Function(List children) explicitListBuilder; + final ExplicitListWidgetBuilder explicitListBuilder; /// The builder for a template-based list of children. - final Widget Function( - BuildContext context, - List list, - String componentId, - String dataBinding, - ) - templateListWidgetBuilder; + final TemplateListWidgetBuilder templateListWidgetBuilder; @override Widget build(BuildContext context) { - // Accept either a List of string IDs or the correct output, which is an - // object with "explicitList" as the list property to use. This is - // because the AIs seem to often get confused and generate just a list - // of IDs. final explicitList = (childrenData is List) ? (childrenData as List).cast() : ((childrenData as JsonMap?)?['explicitList'] as List?) @@ -75,22 +75,28 @@ class ComponentChildrenBuilder extends StatelessWidget { if (template != null) { final dataBinding = template['dataBinding'] as String; final componentId = template['componentId'] as String; - final listNotifier = dataContext.subscribe>( + genUiLogger.finest( + 'Widget $componentId subscribing to ${dataContext.path}', + ); + final dataNotifier = dataContext.subscribe>( DataPath(dataBinding), ); - return ValueListenableBuilder?>( - valueListenable: listNotifier, - builder: (context, list, child) { - genUiLogger.info('buildChildrenFromComponentData: list=$list'); - if (list == null) { - return const SizedBox.shrink(); - } - return templateListWidgetBuilder( - context, - list, - componentId, - dataBinding, + return ValueListenableBuilder?>( + valueListenable: dataNotifier, + builder: (context, data, child) { + genUiLogger.info( + 'ComponentChildrenBuilder: data type: ${data.runtimeType}, ' + 'value: $data', ); + if (data != null) { + return templateListWidgetBuilder( + context, + data, + componentId, + dataBinding, + ); + } + return const SizedBox.shrink(); }, ); } diff --git a/packages/flutter_genui/lib/src/conversation/gen_ui_conversation.dart b/packages/flutter_genui/lib/src/conversation/gen_ui_conversation.dart index c3ded690e..e0c3632f0 100644 --- a/packages/flutter_genui/lib/src/conversation/gen_ui_conversation.dart +++ b/packages/flutter_genui/lib/src/conversation/gen_ui_conversation.dart @@ -24,8 +24,8 @@ import '../model/ui_models.dart'; class GenUiConversation { /// Creates a new [GenUiConversation]. /// - /// Callbacks like [onSurfaceAdded] and [onSurfaceDeleted] can be provided to - /// react to UI changes initiated by the AI. + /// Callbacks like [onSurfaceAdded], [onSurfaceUpdated] and [onSurfaceDeleted] + /// can be provided to react to UI changes initiated by the AI. GenUiConversation({ this.onSurfaceAdded, this.onSurfaceUpdated, @@ -140,7 +140,7 @@ class GenUiConversation { /// Returns a [ValueNotifier] for the given [surfaceId]. ValueNotifier surface(String surfaceId) { - return genUiManager.surface(surfaceId); + return genUiManager.getSurfaceNotifier(surfaceId); } /// Sends a user message to the AI to generate a UI response. diff --git a/packages/flutter_genui/lib/src/core/genui_manager.dart b/packages/flutter_genui/lib/src/core/genui_manager.dart index fd35f6654..05b388ca6 100644 --- a/packages/flutter_genui/lib/src/core/genui_manager.dart +++ b/packages/flutter_genui/lib/src/core/genui_manager.dart @@ -62,7 +62,7 @@ abstract interface class GenUiHost { Stream get surfaceUpdates; /// Returns a [ValueNotifier] for the surface with the given [surfaceId]. - ValueNotifier surface(String surfaceId); + ValueNotifier getSurfaceNotifier(String surfaceId); /// The catalog of UI components available to the AI. Catalog get catalog; @@ -133,8 +133,16 @@ class GenUiManager implements GenUiHost { final Catalog catalog; @override - ValueNotifier surface(String surfaceId) { - return _surfaces.putIfAbsent(surfaceId, () => ValueNotifier(null)); + ValueNotifier getSurfaceNotifier(String surfaceId) { + if (!_surfaces.containsKey(surfaceId)) { + genUiLogger.fine('Adding new surface $surfaceId'); + } else { + genUiLogger.fine('Fetching surface notifier for $surfaceId'); + } + return _surfaces.putIfAbsent( + surfaceId, + () => ValueNotifier(null), + ); } /// Disposes of the resources used by this manager. @@ -150,8 +158,11 @@ class GenUiManager implements GenUiHost { void handleMessage(A2uiMessage message) { switch (message) { case SurfaceUpdate(): + // No need for SurfaceAdded here because A2uiMessage will never generate + // those. We decide here if the surface is new or not, and generate a + // SurfaceAdded event if so. final surfaceId = message.surfaceId; - final notifier = surface(surfaceId); + final notifier = getSurfaceNotifier(surfaceId); final isNew = notifier.value == null; var uiDefinition = notifier.value ?? UiDefinition(surfaceId: surfaceId); final newComponents = Map.of(uiDefinition.components); @@ -159,9 +170,6 @@ class GenUiManager implements GenUiHost { newComponents[component.id] = component; } uiDefinition = uiDefinition.copyWith(components: newComponents); - - // Implement garbage collection of unused nodes here. - notifier.value = uiDefinition; if (isNew) { genUiLogger.info('Adding surface $surfaceId'); @@ -170,24 +178,27 @@ class GenUiManager implements GenUiHost { genUiLogger.info('Updating surface $surfaceId'); _surfaceUpdates.add(SurfaceUpdated(surfaceId, uiDefinition)); } - case DataModelUpdate(): - final path = message.path ?? '/'; - genUiLogger.info( - 'Updating data model for surface ${message.surfaceId} at path ' - '$path with contents: ${message.contents}', - ); - final dataModel = dataModelForSurface(message.surfaceId); - dataModel.update(DataPath(path), message.contents); - break; case BeginRendering(): - final notifier = surface(message.surfaceId); + dataModelForSurface(message.surfaceId); + final notifier = getSurfaceNotifier(message.surfaceId); final uiDefinition = notifier.value ?? UiDefinition(surfaceId: message.surfaceId); final newUiDefinition = uiDefinition.copyWith( rootComponentId: message.root, ); notifier.value = newUiDefinition; + genUiLogger.info('Started rendering ${message.surfaceId}'); _surfaceUpdates.add(SurfaceUpdated(message.surfaceId, newUiDefinition)); + case DataModelUpdate(): + final path = message.path ?? '/'; + genUiLogger.info( + 'Updating data model for surface ${message.surfaceId} at path ' + '$path with contents:\n' + '${const JsonEncoder.withIndent(' ').convert(message.contents)}', + ); + final dataModel = dataModelForSurface(message.surfaceId); + dataModel.update(DataPath(path), message.contents); + break; case SurfaceDeletion(): final surfaceId = message.surfaceId; if (_surfaces.containsKey(surfaceId)) { diff --git a/packages/flutter_genui/lib/src/core/genui_surface.dart b/packages/flutter_genui/lib/src/core/genui_surface.dart index 63ffb47e7..2735e82a6 100644 --- a/packages/flutter_genui/lib/src/core/genui_surface.dart +++ b/packages/flutter_genui/lib/src/core/genui_surface.dart @@ -41,10 +41,11 @@ class GenUiSurface extends StatefulWidget { class _GenUiSurfaceState extends State { @override Widget build(BuildContext context) { + genUiLogger.fine('Outer Building surface ${widget.surfaceId}'); return ValueListenableBuilder( - valueListenable: widget.host.surface(widget.surfaceId), + valueListenable: widget.host.getSurfaceNotifier(widget.surfaceId), builder: (context, definition, child) { - genUiLogger.info('Building surface ${widget.surfaceId}'); + genUiLogger.fine('Building surface ${widget.surfaceId}'); if (definition == null) { genUiLogger.info('Surface ${widget.surfaceId} has no definition.'); return widget.defaultBuilder?.call(context) ?? @@ -80,7 +81,7 @@ class _GenUiSurfaceState extends State { } final widgetData = data.componentProperties; - + genUiLogger.finest('Building widget $widgetId'); return widget.host.catalog.buildWidget( id: widgetId, widgetData: widgetData, @@ -94,7 +95,7 @@ class _GenUiSurfaceState extends State { void _dispatchEvent(UiEvent event) { if (event is UserActionEvent && event.name == 'showModal') { - final definition = widget.host.surface(widget.surfaceId).value; + final definition = widget.host.getSurfaceNotifier(widget.surfaceId).value; if (definition == null) return; final modalId = event.context['modalId'] as String; final modalComponent = definition.components[modalId]; diff --git a/packages/flutter_genui/lib/src/core/widget_utilities.dart b/packages/flutter_genui/lib/src/core/widget_utilities.dart index a8fa9ef83..0948b8d49 100644 --- a/packages/flutter_genui/lib/src/core/widget_utilities.dart +++ b/packages/flutter_genui/lib/src/core/widget_utilities.dart @@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import '../model/data_model.dart'; +import '../primitives/logging.dart'; import '../primitives/simple_items.dart'; /// A builder widget that simplifies handling of nullable `ValueListenable`s. @@ -44,6 +45,9 @@ class OptionalValueBuilder extends StatelessWidget { extension DataContextExtensions on DataContext { /// Subscribes to a value, which can be a literal or a data-bound path. ValueNotifier subscribeToValue(JsonMap? ref, String literalKey) { + genUiLogger.info( + 'DataContext.subscribeToValue: ref=$ref, literalKey=$literalKey', + ); if (ref == null) return ValueNotifier(null); final path = ref['path'] as String?; final literal = ref[literalKey]; @@ -67,12 +71,12 @@ extension DataContextExtensions on DataContext { /// Subscribes to a boolean value, which can be a literal or a data-bound /// path. ValueNotifier subscribeToBool(JsonMap? ref) { - return subscribeToValue(ref, 'literalString'); + return subscribeToValue(ref, 'literalBoolean'); } - /// Subscribes to a list of strings, which can be a literal or a data-bound + /// Subscribes to a list of objects, which can be a literal or a data-bound /// path. - ValueNotifier?> subscribeToStringArray(JsonMap? ref) { + ValueNotifier?> subscribeToObjectArray(JsonMap? ref) { return subscribeToValue>(ref, 'literalArray'); } } diff --git a/packages/flutter_genui/lib/src/model/data_model.dart b/packages/flutter_genui/lib/src/model/data_model.dart index fb1c0e8a8..fc8128685 100644 --- a/packages/flutter_genui/lib/src/model/data_model.dart +++ b/packages/flutter_genui/lib/src/model/data_model.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:convert'; + import 'package:flutter/foundation.dart'; import '../primitives/logging.dart'; @@ -10,7 +12,8 @@ import '../primitives/simple_items.dart'; @immutable class DataPath { factory DataPath(String path) { - return DataPath._(_split(path), path.startsWith(_separator)); + final segments = path.split('/').where((s) => s.isNotEmpty).toList(); + return DataPath._(segments, path.startsWith(_separator)); } const DataPath._(this.segments, this.isAbsolute); @@ -21,16 +24,6 @@ class DataPath { static final String _separator = '/'; static const DataPath root = DataPath._([], true); - static List _split(String path) { - if (path.startsWith(_separator)) { - path = path.substring(1); - } - if (path.isEmpty) { - return []; - } - return path.split(_separator); - } - String get basename => segments.last; DataPath get dirname => @@ -130,11 +123,28 @@ class DataModel { /// relevant subscribers. void update(DataPath? absolutePath, dynamic contents) { genUiLogger.info( - 'DataModel.update: path=$absolutePath, contents=$contents', + 'DataModel.update: path=$absolutePath, contents=' + '${const JsonEncoder.withIndent(' ').convert(contents)}', ); if (absolutePath == null || absolutePath.segments.isEmpty) { - _data = contents as JsonMap; - _notifySubscribers(DataPath('/')); + if (contents is List) { + _data = _parseDataModelContents(contents); + } else if (contents is Map) { + // Permissive: Allow a map to be sent for the root, even though the + // schema expects a list. + genUiLogger.info( + 'DataModel.update: contents for root path is a Map, not a ' + 'List: $contents', + ); + _data = Map.from(contents); + } else { + genUiLogger.warning( + 'DataModel.update: contents for root path is not a List or ' + 'Map: $contents', + ); + _data = {}; // Fallback + } + _notifySubscribers(DataPath.root); return; } @@ -177,6 +187,59 @@ class DataModel { return _getValue(_data, absolutePath.segments) as T?; } + JsonMap _parseDataModelContents(List contents) { + final newData = {}; + for (final item in contents) { + if (item is Map && item.containsKey('key')) { + final key = item['key'] as String; + Object? value; + var valueCount = 0; + + const valueKeys = [ + 'valueString', + 'valueNumber', + 'valueBoolean', + 'valueMap', + ]; + for (final valueKey in valueKeys) { + if (item.containsKey(valueKey)) { + if (valueCount == 0) { + if (valueKey == 'valueMap') { + if (item[valueKey] is List) { + value = _parseDataModelContents( + (item[valueKey] as List).cast(), + ); + } else { + genUiLogger.warning( + 'valueMap for key "$key" is not a List: ${item[valueKey]}', + ); + } + } else { + value = item[valueKey]; + } + } + valueCount++; + } + } + + if (valueCount == 0) { + genUiLogger.warning( + 'No value field found for key "$key" in contents: $item', + ); + } else if (valueCount > 1) { + genUiLogger.warning( + 'Multiple value fields found for key "$key" in contents: $item. ' + 'Using the first one found.', + ); + } + newData[key] = value; + } else { + genUiLogger.warning('Invalid item in dataModelUpdate contents: $item'); + } + } + return newData; + } + dynamic _getValue(dynamic current, List segments) { if (segments.isEmpty) { return current; @@ -187,8 +250,8 @@ class DataModel { if (current is Map) { return _getValue(current[segment], remaining); - } else if (current is List && segment.startsWith('[')) { - final index = int.tryParse(segment.substring(1, segment.length - 1)); + } else if (current is List) { + final index = int.tryParse(segment); if (index != null && index >= 0 && index < current.length) { return _getValue(current[index], remaining); } @@ -204,9 +267,34 @@ class DataModel { final segment = segments.first; final remaining = segments.sublist(1); - if (segment.startsWith('[')) { - final index = int.tryParse(segment.substring(1, segment.length - 1)); - if (index != null && current is List && index >= 0) { + if (current is Map) { + if (remaining.isEmpty) { + current[segment] = value; + return; + } + + // If we are here, remaining is not empty. + final nextSegment = remaining.first; + final isNextSegmentListIndex = nextSegment.startsWith(RegExp(r'^\d+$')); + + var nextNode = current[segment]; + + if (isNextSegmentListIndex) { + if (nextNode is! List) { + nextNode = []; + current[segment] = nextNode; + } + } else { + // Next segment is a map key + if (nextNode is! Map) { + nextNode = {}; + current[segment] = nextNode; + } + } + _updateValue(nextNode, remaining, value); + } else if (current is List) { + final index = int.tryParse(segment); + if (index != null && index >= 0) { if (remaining.isEmpty) { if (index < current.length) { current[index] = value; @@ -221,34 +309,33 @@ class DataModel { } else { if (index < current.length) { _updateValue(current[index], remaining, value); + } else if (index == current.length) { + // If the index is the length, we're adding a new item which + // should be a map or list based on the next segment. + if (remaining.first.startsWith(RegExp(r'^\d+$'))) { + current.add([]); + } else { + current.add({}); + } + _updateValue(current[index], remaining, value); } else { throw ArgumentError( 'Index out of bounds for nested update: index ($index) is ' - 'greater than or equal to list length (${current.length}).', + 'greater than list length (${current.length}).', ); } } - } - } else { - if (current is Map) { - if (remaining.isEmpty) { - current[segment] = value; - } else { - if (!current.containsKey(segment)) { - if (remaining.first.startsWith('[')) { - current[segment] = []; - } else { - current[segment] = {}; - } - } - _updateValue(current[segment], remaining, value); - } + } else { + genUiLogger.warning('Invalid list index segment: $segment'); } } } void _notifySubscribers(DataPath path) { - genUiLogger.info('DataModel._notifySubscribers: notifying for path=$path'); + genUiLogger.info( + 'DataModel._notifySubscribers: notifying ' + '${_subscriptions.length} subscribers for path=$path', + ); for (final p in _subscriptions.keys) { if (p.startsWith(path) || path.startsWith(p)) { genUiLogger.info(' - Notifying subscriber for path=$p'); diff --git a/packages/flutter_genui/lib/src/model/ui_models.dart b/packages/flutter_genui/lib/src/model/ui_models.dart index 6e5e18d9d..34ecec828 100644 --- a/packages/flutter_genui/lib/src/model/ui_models.dart +++ b/packages/flutter_genui/lib/src/model/ui_models.dart @@ -85,7 +85,8 @@ class UiDefinition { final String? rootComponentId; /// A map of all widget definitions in the UI, keyed by their ID. - final Map components; + Map get components => UnmodifiableMapView(_components); + final Map _components; /// (Future) The styles for this surface. final JsonMap? styles; @@ -94,9 +95,9 @@ class UiDefinition { UiDefinition({ required this.surfaceId, this.rootComponentId, - this.components = const {}, + Map components = const {}, this.styles, - }); + }) : _components = components; /// Creates a copy of this [UiDefinition] with the given fields replaced. UiDefinition copyWith({ @@ -107,7 +108,7 @@ class UiDefinition { return UiDefinition( surfaceId: surfaceId, rootComponentId: rootComponentId ?? this.rootComponentId, - components: components ?? this.components, + components: components ?? _components, styles: styles ?? this.styles, ); } diff --git a/packages/flutter_genui/test/core/genui_manager_test.dart b/packages/flutter_genui/test/core/genui_manager_test.dart index c82a56280..dd4b7dd37 100644 --- a/packages/flutter_genui/test/core/genui_manager_test.dart +++ b/packages/flutter_genui/test/core/genui_manager_test.dart @@ -126,8 +126,8 @@ void main() { }); test('surface() creates a new ValueNotifier if one does not exist', () { - final notifier1 = manager.surface('s1'); - final notifier2 = manager.surface('s1'); + final notifier1 = manager.getSurfaceNotifier('s1'); + final notifier2 = manager.getSurfaceNotifier('s1'); expect(notifier1, same(notifier2)); expect(notifier1.value, isNull); }); diff --git a/packages/flutter_genui/test/model/data_model_test.dart b/packages/flutter_genui/test/model/data_model_test.dart index f6c79974d..15cfffc16 100644 --- a/packages/flutter_genui/test/model/data_model_test.dart +++ b/packages/flutter_genui/test/model/data_model_test.dart @@ -188,5 +188,135 @@ void main() { expect(callCount, 0); }); }); + + group('DataModel Update Parsing', () { + test('parses contents with valueString', () { + dataModel.update(DataPath.root, [ + {'key': 'a', 'valueString': 'hello'}, + ]); + expect(dataModel.getValue(DataPath('/a')), 'hello'); + }); + + test('parses contents with valueNumber', () { + dataModel.update(DataPath.root, [ + {'key': 'b', 'valueNumber': 123}, + ]); + expect(dataModel.getValue(DataPath('/b')), 123); + }); + + test('parses contents with valueBoolean', () { + dataModel.update(DataPath.root, [ + {'key': 'c', 'valueBoolean': true}, + ]); + expect(dataModel.getValue(DataPath('/c')), isTrue); + }); + + test('parses contents with valueMap', () { + dataModel.update(DataPath.root, [ + { + 'key': 'd', + 'valueMap': [ + {'key': 'd1', 'valueString': 'v1'}, + {'key': 'd2', 'valueNumber': 2}, + ], + }, + ]); + expect(dataModel.getValue(DataPath('/d')), {'d1': 'v1', 'd2': 2}); + }); + + test('is permissive with multiple value types', () { + dataModel.update(DataPath.root, [ + {'key': 'e', 'valueString': 'first', 'valueNumber': 999}, + ]); + expect(dataModel.getValue(DataPath('/e')), 'first'); + }); + + test('handles empty contents array', () { + dataModel.update(DataPath('/a'), {'b': 1}); // Initial data + dataModel.update(DataPath.root, []); + expect(dataModel.data, isEmpty); + }); + + test('handles contents with no value field', () { + dataModel.update(DataPath.root, [ + {'key': 'f'}, + ]); + expect(dataModel.getValue(DataPath('/f')), isNull); + }); + }); + }); + + group('DataModel _getValue and _updateValue consistency', () { + late DataModel dataModel; + + setUp(() { + dataModel = DataModel(); + }); + + test('Map: set and get', () { + dataModel.update(DataPath('/a/b'), 1); + expect(dataModel.getValue(DataPath('/a/b')), 1); + }); + + test('List: set and get', () { + dataModel.update(DataPath('/a/0'), 'hello'); + expect(dataModel.getValue(DataPath('/a/0')), 'hello'); + }); + + test('List: append and get', () { + dataModel.update(DataPath('/a/0'), 'hello'); + dataModel.update(DataPath('/a/1'), 'world'); + expect(dataModel.getValue(DataPath('/a/0')), 'hello'); + expect(dataModel.getValue(DataPath('/a/1')), 'world'); + }); + + test('Nested Map/List: set and get', () { + dataModel.update(DataPath('/a/b/0/c'), 123); + expect(dataModel.getValue(DataPath('/a/b/0/c')), 123); + }); + + test('Map: non-existent key returns null', () { + dataModel.update(DataPath('/a/b'), 1); + expect(dataModel.getValue(DataPath('/a/c')), isNull); + }); + + test('List: out of bounds index returns null', () { + dataModel.update(DataPath('/a/0'), 'hello'); + expect(dataModel.getValue(DataPath('/a/1')), isNull); + }); + + test('List: update existing index', () { + dataModel.update(DataPath('/a/0'), 'hello'); + dataModel.update(DataPath('/a/0'), 'world'); + expect(dataModel.getValue(DataPath('/a/0')), 'world'); + }); + + test('Empty path on getValue returns current data', () { + dataModel.update(DataPath('/a'), {'b': 1}); + expect(dataModel.getValue(DataPath('/a')), {'b': 1}); + }); + + test('Nested structures are created automatically', () { + dataModel.update(DataPath('/a/b/0/c'), 123); + expect( + dataModel.getValue(DataPath('/a/b/0/c')), + 123, + reason: 'Should create nested map and list', + ); + + dataModel.update(DataPath('/x/y/z'), 'hello'); + expect( + dataModel.getValue(DataPath('/x/y/z')), + 'hello', + reason: 'Should create nested maps', + ); + + dataModel.update(DataPath('/list/0/0'), 'inner list'); + expect( + dataModel.getValue(DataPath('/list/0/0')), + 'inner list', + reason: 'Should create nested lists', + ); + }); }); } diff --git a/packages/flutter_genui_a2ui/lib/src/a2ui_agent_connector.dart b/packages/flutter_genui_a2ui/lib/src/a2ui_agent_connector.dart index 1e1ea9c8f..fc5e54e13 100644 --- a/packages/flutter_genui_a2ui/lib/src/a2ui_agent_connector.dart +++ b/packages/flutter_genui_a2ui/lib/src/a2ui_agent_connector.dart @@ -80,15 +80,17 @@ class A2uiAgentConnector { /// /// Returns the text response from the agent, if any. Future connectAndSend(genui.ChatMessage chatMessage) async { + final parts = (chatMessage is genui.UserMessage) + ? chatMessage.parts + : (chatMessage is genui.UserUiInteractionMessage) + ? chatMessage.parts + : []; final message = A2AMessage() ..messageId = const Uuid().v4() ..role = 'user' - ..parts = (chatMessage as genui.UserMessage).parts - .whereType() - .map((part) { - return A2ATextPart()..text = part.text; - }) - .toList(); + ..parts = parts.whereType().map((part) { + return A2ATextPart()..text = part.text; + }).toList(); if (taskId != null) { message.referenceTaskIds = [taskId!]; @@ -103,7 +105,10 @@ class A2uiAgentConnector { _log.info('--- OUTGOING REQUEST ---'); _log.info('URL: ${url.toString()}'); _log.info('Method: message/stream'); - _log.info('Payload: ${jsonEncode(payload.toJson())}'); + _log.info( + 'Payload: ' + '${const JsonEncoder.withIndent(' ').convert(payload.toJson())}', + ); _log.info('----------------------'); final events = client.sendMessageStream(payload); @@ -209,7 +214,10 @@ class A2uiAgentConnector { } void _processA2uiMessages(Map data) { - _log.finer('Processing a2ui messages from data part: $data'); + _log.finer( + 'Processing a2ui messages from data part:\n' + '${const JsonEncoder.withIndent(' ').convert(data)}', + ); if (data.containsKey('surfaceUpdate') || data.containsKey('dataModelUpdate') || data.containsKey('beginRendering') || @@ -217,7 +225,7 @@ class A2uiAgentConnector { if (!_controller.isClosed) { _log.finest( 'Adding message to stream: ' - '${jsonEncode(data)}', + '${const JsonEncoder.withIndent(' ').convert(data)}', ); _controller.add(genui.A2uiMessage.fromJson(data)); } diff --git a/server_to_client.json b/packages/flutter_genui_a2ui/server_to_client.json similarity index 89% rename from server_to_client.json rename to packages/flutter_genui_a2ui/server_to_client.json index 8decfc458..8c0cd234b 100644 --- a/server_to_client.json +++ b/packages/flutter_genui_a2ui/server_to_client.json @@ -53,6 +53,10 @@ "type": "string", "description": "The unique identifier for this component." }, + "weight": { + "type": "number", + "description": "The relative weight of this component within a Row or Column. This corresponds to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column." + }, "component": { "type": "object", "description": "A wrapper object that MUST contain exactly one key, which is the name of the component type (e.g., 'Heading'). The value is an object containing the properties for that specific component.", @@ -62,7 +66,7 @@ "properties": { "text": { "type": "object", - "description": "The text content for the heading. This can be a literal string or a reference to a value in the data model ('path', e.g. 'doc.title').", + "description": "The text content for the heading. This can be a literal string or a reference to a value in the data model ('path', e.g. '/doc/title').", "properties": { "literalString": { "type": "string" @@ -85,7 +89,7 @@ "properties": { "text": { "type": "object", - "description": "The text content to display. This can be a literal string or a reference to a value in the data model ('path', e.g. 'hotel.description').", + "description": "The text content to display. This can be a literal string or a reference to a value in the data model ('path', e.g. '/hotel/description').", "properties": { "literalString": { "type": "string" @@ -103,7 +107,7 @@ "properties": { "url": { "type": "object", - "description": "The URL of the image to display. This can be a literal string ('literal') or a reference to a value in the data model ('path', e.g. 'thumbnail.url').", + "description": "The URL of the image to display. This can be a literal string ('literal') or a reference to a value in the data model ('path', e.g. '/thumbnail/url').", "properties": { "literalString": { "type": "string" @@ -127,12 +131,30 @@ }, "required": ["url"] }, + "Icon": { + "type": "object", + "properties": { + "name": { + "type": "object", + "description": "The name of the icon to display. This can be a literal string ('literal') or a reference to a value in the data model ('path', e.g. '/icon/name').", + "properties": { + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } + } + } + }, + "required": ["name"] + }, "Video": { "type": "object", "properties": { "url": { "type": "object", - "description": "The URL of the video to display. This can be a literal string or a reference to a value in the data model ('path', e.g. 'video.url').", + "description": "The URL of the video to display. This can be a literal string or a reference to a value in the data model ('path', e.g. '/video/url').", "properties": { "literalString": { "type": "string" @@ -150,7 +172,7 @@ "properties": { "url": { "type": "object", - "description": "The URL of the audio to be played. This can be a literal string ('literal') or a reference to a value in the data model ('path', e.g. 'song.url').", + "description": "The URL of the audio to be played. This can be a literal string ('literal') or a reference to a value in the data model ('path', e.g. '/song/url').", "properties": { "literalString": { "type": "string" @@ -162,7 +184,7 @@ }, "description": { "type": "object", - "description": "A description of the audio, such as a title or summary. This can be a literal string or a reference to a value in the data model ('path', e.g. 'song.title').", + "description": "A description of the audio, such as a title or summary. This can be a literal string or a reference to a value in the data model ('path', e.g. '/song/title').", "properties": { "literalString": { "type": "string" @@ -190,7 +212,7 @@ }, "template": { "type": "object", - "description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the list in the data model.", + "description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.", "properties": { "componentId": { "type": "string" @@ -238,7 +260,7 @@ }, "template": { "type": "object", - "description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the list in the data model.", + "description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.", "properties": { "componentId": { "type": "string" @@ -286,7 +308,7 @@ }, "template": { "type": "object", - "description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the list in the data model.", + "description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.", "properties": { "componentId": { "type": "string" @@ -333,7 +355,7 @@ "properties": { "title": { "type": "object", - "description": "The tab title. Defines the value as either a literal value or a path to data model value (e.g. 'options.title').", + "description": "The tab title. Defines the value as either a literal value or a path to data model value (e.g. '/options/title').", "properties": { "literalString": { "type": "string" @@ -384,6 +406,10 @@ "type": "string", "description": "The ID of the component to display in the button, typically a Text component." }, + "primary": { + "type": "boolean", + "description": "Whether or not this button is the 'primary' button for the UI it is connected to. Reserved for submit buttons, or similar, that define an important action. Secondary buttons (e.g. cancel buttons) should not have this set to true. There should only be one primary button among a group of related buttons." + }, "action": { "type": "object", "description": "The client-side action to be dispatched when the button is clicked. It includes the action's name and an optional context payload.", @@ -401,7 +427,7 @@ }, "value": { "type": "object", - "description": "Defines the value to be included in the context as either a literal value or a path to a data model value (e.g. 'user.name').", + "description": "Defines the value to be included in the context as either a literal value or a path to a data model value (e.g. '/user/name').", "properties": { "path": { "type": "string" @@ -432,7 +458,7 @@ "properties": { "label": { "type": "object", - "description": "The text to display next to the checkbox. Defines the value as either a literal value or a path to data model ('path', e.g. 'option.label').", + "description": "The text to display next to the checkbox. Defines the value as either a literal value or a path to data model ('path', e.g. '/option/label').", "properties": { "literalString": { "type": "string" @@ -444,7 +470,7 @@ }, "value": { "type": "object", - "description": "The current state of the checkbox (true for checked, false for unchecked). This can be a literal boolean ('literalBoolean') or a reference to a value in the data model ('path', e.g. 'filter.open').", + "description": "The current state of the checkbox (true for checked, false for unchecked). This can be a literal boolean ('literalBoolean') or a reference to a value in the data model ('path', e.g. '/filter/open').", "properties": { "literalBoolean": { "type": "boolean" @@ -462,7 +488,7 @@ "properties": { "label": { "type": "object", - "description": "The text label for the input field. This can be a literal string or a reference to a value in the data model ('path, e.g. 'user.name').", + "description": "The text label for the input field. This can be a literal string or a reference to a value in the data model ('path, e.g. '/user/name').", "properties": { "literalString": { "type": "string" @@ -474,7 +500,7 @@ }, "text": { "type": "object", - "description": "The value of the text field. This can be a literal string or a reference to a value in the data model ('path', e.g. 'user.name').", + "description": "The value of the text field. This can be a literal string or a reference to a value in the data model ('path', e.g. '/user/name').", "properties": { "literalString": { "type": "string" @@ -507,7 +533,7 @@ "properties": { "value": { "type": "object", - "description": "The selected date and/or time value. This can be a literal string ('literalString') or a reference to a value in the data model ('path', e.g. 'user.dob').", + "description": "The selected date and/or time value. This can be a literal string ('literalString') or a reference to a value in the data model ('path', e.g. '/user/dob').", "properties": { "literalString": { "type": "string" @@ -537,7 +563,7 @@ "properties": { "selections": { "type": "object", - "description": "The currently selected values for the component. This can be a literal array of strings or a path to an array in the data model('path', e.g. 'hotel.options').", + "description": "The currently selected values for the component. This can be a literal array of strings or a path to an array in the data model('path', e.g. '/hotel/options').", "properties": { "literalArray": { "type": "array", @@ -558,7 +584,7 @@ "properties": { "label": { "type": "object", - "description": "The text to display for this option. This can be a literal string or a reference to a value in the data model (e.g. 'option.label').", + "description": "The text to display for this option. This can be a literal string or a reference to a value in the data model (e.g. '/option/label').", "properties": { "literalString": { "type": "string" @@ -588,7 +614,7 @@ "properties": { "value": { "type": "object", - "description": "The current value of the slider. This can be a literal number ('literalNumber') or a reference to a value in the data model ('path', e.g. 'restaurant.cost').", + "description": "The current value of the slider. This can be a literal number ('literalNumber') or a reference to a value in the data model ('path', e.g. '/restaurant/cost').", "properties": { "literalNumber": { "type": "number" @@ -628,7 +654,7 @@ }, "path": { "type": "string", - "description": "An optional path to a location within the data model (e.g., 'user.name'). If omitted, the entire data model will be replaced." + "description": "An optional path to a location within the data model (e.g., '/user/name'). If omitted, or set to '/', the entire data model will be replaced." }, "contents": { "type": "array", @@ -650,11 +676,16 @@ "valueBoolean": { "type": "boolean" }, - "valueList": { + "valueMap": { + "description": "Represents a map as an adjacency list.", "type": "array", "items": { "type": "object", + "description": "One entry in the map. Exactly one 'value*' property should be provided alongside the key.", "properties": { + "key": { + "type": "string" + }, "valueString": { "type": "string" }, @@ -664,10 +695,12 @@ "valueBoolean": { "type": "boolean" } - } + }, + "required": ["key"] } } - } + }, + "required": ["key"] } } }, diff --git a/packages/spikes/a2ui_client/lib/src/core/a2ui_agent_connector.dart b/packages/spikes/a2ui_client/lib/src/core/a2ui_agent_connector.dart index 997395452..efde2be39 100644 --- a/packages/spikes/a2ui_client/lib/src/core/a2ui_agent_connector.dart +++ b/packages/spikes/a2ui_client/lib/src/core/a2ui_agent_connector.dart @@ -196,9 +196,9 @@ class A2uiAgentConnector { if (!_controller.isClosed) { _log.finest( 'Adding message to stream: ' - '${jsonEncode(message)}', + '${const JsonEncoder.withIndent(' ').convert(message)}', ); - _controller.add(jsonEncode(message)); + _controller.add(const JsonEncoder.withIndent(' ').convert(message)); } } } else { From 6eae226210ad0776928e45843020aff0e36728d8 Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Thu, 30 Oct 2025 12:39:57 -0700 Subject: [PATCH 2/5] Implement flex weight. --- .../catalog/checkbox_filter_chips_input.dart | 1 + .../lib/src/catalog/date_input_chip.dart | 1 + .../lib/src/catalog/information_card.dart | 1 + .../lib/src/catalog/input_group.dart | 1 + .../travel_app/lib/src/catalog/itinerary.dart | 1 + .../lib/src/catalog/listings_booker.dart | 1 + .../catalog/options_filter_chip_input.dart | 1 + .../lib/src/catalog/tabbed_sections.dart | 1 + .../lib/src/catalog/text_input_chip.dart | 1 + .../travel_app/lib/src/catalog/trailhead.dart | 1 + .../lib/src/catalog/travel_carousel.dart | 1 + .../checkbox_filter_chips_input_test.dart | 1 + .../travel_app/test/date_input_chip_test.dart | 4 + .../travel_app/test/input_group_test.dart | 2 + examples/travel_app/test/itinerary_test.dart | 1 + .../test/options_filter_chip_input_test.dart | 2 + .../travel_app/test/tabbed_sections_test.dart | 1 + examples/travel_app/test/trailhead_test.dart | 2 + .../travel_app/test/travel_carousel_test.dart | 3 + .../catalog/core_widgets/audio_player.dart | 1 + .../lib/src/catalog/core_widgets/button.dart | 1 + .../lib/src/catalog/core_widgets/card.dart | 1 + .../src/catalog/core_widgets/check_box.dart | 1 + .../lib/src/catalog/core_widgets/column.dart | 33 ++-- .../catalog/core_widgets/date_time_input.dart | 1 + .../lib/src/catalog/core_widgets/divider.dart | 1 + .../lib/src/catalog/core_widgets/heading.dart | 1 + .../lib/src/catalog/core_widgets/image.dart | 8 +- .../lib/src/catalog/core_widgets/list.dart | 19 ++- .../lib/src/catalog/core_widgets/modal.dart | 1 + .../catalog/core_widgets/multiple_choice.dart | 1 + .../lib/src/catalog/core_widgets/row.dart | 29 +++- .../lib/src/catalog/core_widgets/slider.dart | 1 + .../lib/src/catalog/core_widgets/tabs.dart | 1 + .../lib/src/catalog/core_widgets/text.dart | 1 + .../src/catalog/core_widgets/text_field.dart | 1 + .../lib/src/catalog/core_widgets/video.dart | 1 + .../catalog/core_widgets/widget_helpers.dart | 17 +- .../lib/src/core/genui_surface.dart | 1 + .../lib/src/model/a2ui_schemas.dart | 4 + .../flutter_genui/lib/src/model/catalog.dart | 2 + .../lib/src/model/catalog_item.dart | 5 + .../lib/src/model/ui_models.dart | 24 ++- .../catalog/core_widgets/column_test.dart | 151 ++++++++++++++++++ .../test/catalog/core_widgets/row_test.dart | 89 +++++++++++ packages/flutter_genui/test/catalog_test.dart | 2 + .../test/core/ui_tools_test.dart | 1 + packages/flutter_genui/test/image_test.dart | 1 + pubspec.yaml | 3 + 49 files changed, 397 insertions(+), 33 deletions(-) create mode 100644 packages/flutter_genui/test/catalog/core_widgets/column_test.dart diff --git a/examples/travel_app/lib/src/catalog/checkbox_filter_chips_input.dart b/examples/travel_app/lib/src/catalog/checkbox_filter_chips_input.dart index 72fce583a..16e8322ad 100644 --- a/examples/travel_app/lib/src/catalog/checkbox_filter_chips_input.dart +++ b/examples/travel_app/lib/src/catalog/checkbox_filter_chips_input.dart @@ -109,6 +109,7 @@ final checkboxFilterChipsInput = CatalogItem( required dispatchEvent, required context, required dataContext, + required getComponent, }) { final checkboxFilterChipsData = _CheckboxFilterChipsInputData.fromMap( data as Map, diff --git a/examples/travel_app/lib/src/catalog/date_input_chip.dart b/examples/travel_app/lib/src/catalog/date_input_chip.dart index 30627dad0..51b7bbda4 100644 --- a/examples/travel_app/lib/src/catalog/date_input_chip.dart +++ b/examples/travel_app/lib/src/catalog/date_input_chip.dart @@ -136,6 +136,7 @@ final dateInputChip = CatalogItem( required dispatchEvent, required context, required dataContext, + required getComponent, }) { final datePickerData = _DatePickerData.fromMap(data as JsonMap); final notifier = dataContext.subscribeToString(datePickerData.value); diff --git a/examples/travel_app/lib/src/catalog/information_card.dart b/examples/travel_app/lib/src/catalog/information_card.dart index 5f41aec2d..ac4486b1e 100644 --- a/examples/travel_app/lib/src/catalog/information_card.dart +++ b/examples/travel_app/lib/src/catalog/information_card.dart @@ -90,6 +90,7 @@ final informationCard = CatalogItem( required dispatchEvent, required context, required dataContext, + required getComponent, }) { final cardData = _InformationCardData.fromMap( data as Map, diff --git a/examples/travel_app/lib/src/catalog/input_group.dart b/examples/travel_app/lib/src/catalog/input_group.dart index 1c9fa82b6..213cf9699 100644 --- a/examples/travel_app/lib/src/catalog/input_group.dart +++ b/examples/travel_app/lib/src/catalog/input_group.dart @@ -126,6 +126,7 @@ final inputGroup = CatalogItem( required dispatchEvent, required context, required dataContext, + required getComponent, }) { final inputGroupData = _InputGroupData.fromMap( data as Map, diff --git a/examples/travel_app/lib/src/catalog/itinerary.dart b/examples/travel_app/lib/src/catalog/itinerary.dart index 6d53cfd5a..6cac200df 100644 --- a/examples/travel_app/lib/src/catalog/itinerary.dart +++ b/examples/travel_app/lib/src/catalog/itinerary.dart @@ -227,6 +227,7 @@ final itinerary = CatalogItem( required dispatchEvent, required context, required dataContext, + required getComponent, }) { final itineraryData = _ItineraryData.fromMap( data as Map, diff --git a/examples/travel_app/lib/src/catalog/listings_booker.dart b/examples/travel_app/lib/src/catalog/listings_booker.dart index aaa3afc78..f82034c93 100644 --- a/examples/travel_app/lib/src/catalog/listings_booker.dart +++ b/examples/travel_app/lib/src/catalog/listings_booker.dart @@ -60,6 +60,7 @@ final listingsBooker = CatalogItem( required dispatchEvent, required context, required dataContext, + required getComponent, }) { final listingsBookerData = _ListingsBookerData.fromMap( data as Map, diff --git a/examples/travel_app/lib/src/catalog/options_filter_chip_input.dart b/examples/travel_app/lib/src/catalog/options_filter_chip_input.dart index 0b0a22945..b500f2054 100644 --- a/examples/travel_app/lib/src/catalog/options_filter_chip_input.dart +++ b/examples/travel_app/lib/src/catalog/options_filter_chip_input.dart @@ -101,6 +101,7 @@ final optionsFilterChipInput = CatalogItem( required dispatchEvent, required context, required dataContext, + required getComponent, }) { final optionsFilterChipData = _OptionsFilterChipInputData.fromMap( data as Map, diff --git a/examples/travel_app/lib/src/catalog/tabbed_sections.dart b/examples/travel_app/lib/src/catalog/tabbed_sections.dart index 0039a4d3d..26f7bcf13 100644 --- a/examples/travel_app/lib/src/catalog/tabbed_sections.dart +++ b/examples/travel_app/lib/src/catalog/tabbed_sections.dart @@ -110,6 +110,7 @@ final tabbedSections = CatalogItem( required dispatchEvent, required context, required dataContext, + required getComponent, }) { final tabbedSectionsData = _TabbedSectionsData.fromMap( data as Map, diff --git a/examples/travel_app/lib/src/catalog/text_input_chip.dart b/examples/travel_app/lib/src/catalog/text_input_chip.dart index e2163b2c7..d4cb9f92b 100644 --- a/examples/travel_app/lib/src/catalog/text_input_chip.dart +++ b/examples/travel_app/lib/src/catalog/text_input_chip.dart @@ -79,6 +79,7 @@ final textInputChip = CatalogItem( required dispatchEvent, required context, required dataContext, + required getComponent, }) { final textInputChipData = _TextInputChipData.fromMap( data as Map, diff --git a/examples/travel_app/lib/src/catalog/trailhead.dart b/examples/travel_app/lib/src/catalog/trailhead.dart index fec211523..b2e117f56 100644 --- a/examples/travel_app/lib/src/catalog/trailhead.dart +++ b/examples/travel_app/lib/src/catalog/trailhead.dart @@ -78,6 +78,7 @@ final trailhead = CatalogItem( required dispatchEvent, required context, required dataContext, + required getComponent, }) { final trailheadData = _TrailheadData.fromMap( data as Map, diff --git a/examples/travel_app/lib/src/catalog/travel_carousel.dart b/examples/travel_app/lib/src/catalog/travel_carousel.dart index 233935dff..0aebe5e10 100644 --- a/examples/travel_app/lib/src/catalog/travel_carousel.dart +++ b/examples/travel_app/lib/src/catalog/travel_carousel.dart @@ -73,6 +73,7 @@ final travelCarousel = CatalogItem( required dispatchEvent, required context, required dataContext, + required getComponent, }) { final carouselData = _TravelCarouselData.fromMap( (data as Map).cast(), diff --git a/examples/travel_app/test/checkbox_filter_chips_input_test.dart b/examples/travel_app/test/checkbox_filter_chips_input_test.dart index 5249d78fd..8d5f48117 100644 --- a/examples/travel_app/test/checkbox_filter_chips_input_test.dart +++ b/examples/travel_app/test/checkbox_filter_chips_input_test.dart @@ -31,6 +31,7 @@ void main() { dispatchEvent: (_) {}, context: context, dataContext: DataContext(DataModel(), '/'), + getComponent: (String componentId) => null, ), ); }, diff --git a/examples/travel_app/test/date_input_chip_test.dart b/examples/travel_app/test/date_input_chip_test.dart index 212ff1a1f..618398472 100644 --- a/examples/travel_app/test/date_input_chip_test.dart +++ b/examples/travel_app/test/date_input_chip_test.dart @@ -28,6 +28,7 @@ void main() { dispatchEvent: (event) {}, context: context, dataContext: DataContext(dataModel, '/'), + getComponent: (String componentId) => null, ); }, ), @@ -59,6 +60,7 @@ void main() { dispatchEvent: (event) {}, context: context, dataContext: DataContext(dataModel, '/'), + getComponent: (String componentId) => null, ); }, ), @@ -95,6 +97,7 @@ void main() { dispatchEvent: (event) {}, context: context, dataContext: DataContext(dataModel, '/'), + getComponent: (String componentId) => null, ); }, ), @@ -132,6 +135,7 @@ void main() { dispatchEvent: (event) {}, context: context, dataContext: DataContext(dataModel, '/'), + getComponent: (String componentId) => null, ); }, ), diff --git a/examples/travel_app/test/input_group_test.dart b/examples/travel_app/test/input_group_test.dart index b79fdb896..63e57b729 100644 --- a/examples/travel_app/test/input_group_test.dart +++ b/examples/travel_app/test/input_group_test.dart @@ -35,6 +35,7 @@ void main() { }, context: context, dataContext: DataContext(DataModel(), '/'), + getComponent: (String componentId) => null, ); }, ), @@ -78,6 +79,7 @@ void main() { dispatchEvent: (UiEvent _) {}, context: context, dataContext: DataContext(DataModel(), '/'), + getComponent: (String componentId) => null, ); }, ), diff --git a/examples/travel_app/test/itinerary_test.dart b/examples/travel_app/test/itinerary_test.dart index 0039cb8d5..157c44610 100644 --- a/examples/travel_app/test/itinerary_test.dart +++ b/examples/travel_app/test/itinerary_test.dart @@ -53,6 +53,7 @@ void main() { dispatchEvent: mockDispatchEvent, context: tester.element(find.byType(Container)), dataContext: DataContext(DataModel(), '/'), + getComponent: (String componentId) => null, ); // 2. Pump the widget diff --git a/examples/travel_app/test/options_filter_chip_input_test.dart b/examples/travel_app/test/options_filter_chip_input_test.dart index 95cccf368..650a9a106 100644 --- a/examples/travel_app/test/options_filter_chip_input_test.dart +++ b/examples/travel_app/test/options_filter_chip_input_test.dart @@ -32,6 +32,7 @@ void main() { dispatchEvent: (event) {}, context: context, dataContext: DataContext(dataModel, '/'), + getComponent: (String componentId) => null, ); }, ), @@ -90,6 +91,7 @@ void main() { dispatchEvent: (event) {}, context: context, dataContext: DataContext(dataModel, '/'), + getComponent: (String componentId) => null, ); }, ), diff --git a/examples/travel_app/test/tabbed_sections_test.dart b/examples/travel_app/test/tabbed_sections_test.dart index 08dbaa93b..c77bfa6cb 100644 --- a/examples/travel_app/test/tabbed_sections_test.dart +++ b/examples/travel_app/test/tabbed_sections_test.dart @@ -53,6 +53,7 @@ void main() { dispatchEvent: (event) {}, context: context, dataContext: DataContext(DataModel(), '/'), + getComponent: (String componentId) => null, ); }, ), diff --git a/examples/travel_app/test/trailhead_test.dart b/examples/travel_app/test/trailhead_test.dart index 9d1a6e360..a8edd7fc6 100644 --- a/examples/travel_app/test/trailhead_test.dart +++ b/examples/travel_app/test/trailhead_test.dart @@ -35,6 +35,7 @@ void main() { }, context: context, dataContext: DataContext(DataModel(), '/'), + getComponent: (String componentId) => null, ); }, ), @@ -75,6 +76,7 @@ void main() { dispatchEvent: (event) {}, context: context, dataContext: DataContext(DataModel(), '/'), + getComponent: (String componentId) => null, ); }, ), diff --git a/examples/travel_app/test/travel_carousel_test.dart b/examples/travel_app/test/travel_carousel_test.dart index d77b0f115..1ad9c6d3b 100644 --- a/examples/travel_app/test/travel_carousel_test.dart +++ b/examples/travel_app/test/travel_carousel_test.dart @@ -48,6 +48,7 @@ void main() { }, context: context, dataContext: DataContext(DataModel(), '/'), + getComponent: (String componentId) => null, ); }, ), @@ -109,6 +110,7 @@ void main() { }, context: context, dataContext: DataContext(DataModel(), '/'), + getComponent: (String componentId) => null, ); }, ), @@ -143,6 +145,7 @@ void main() { dispatchEvent: (event) {}, context: context, dataContext: DataContext(DataModel(), '/'), + getComponent: (String componentId) => null, ); }, ), diff --git a/packages/flutter_genui/lib/src/catalog/core_widgets/audio_player.dart b/packages/flutter_genui/lib/src/catalog/core_widgets/audio_player.dart index 941a48b6e..3ade33144 100644 --- a/packages/flutter_genui/lib/src/catalog/core_widgets/audio_player.dart +++ b/packages/flutter_genui/lib/src/catalog/core_widgets/audio_player.dart @@ -35,6 +35,7 @@ final audioPlayer = CatalogItem( required dispatchEvent, required context, required dataContext, + required getComponent, }) { return ConstrainedBox( constraints: const BoxConstraints(maxWidth: 200, maxHeight: 100), diff --git a/packages/flutter_genui/lib/src/catalog/core_widgets/button.dart b/packages/flutter_genui/lib/src/catalog/core_widgets/button.dart index e7091a1b1..e1a6ced58 100644 --- a/packages/flutter_genui/lib/src/catalog/core_widgets/button.dart +++ b/packages/flutter_genui/lib/src/catalog/core_widgets/button.dart @@ -66,6 +66,7 @@ final button = CatalogItem( required dispatchEvent, required context, required dataContext, + required getComponent, }) { final buttonData = _ButtonData.fromMap(data as JsonMap); final child = buildChild(buttonData.child); diff --git a/packages/flutter_genui/lib/src/catalog/core_widgets/card.dart b/packages/flutter_genui/lib/src/catalog/core_widgets/card.dart index e99315d38..36967e300 100644 --- a/packages/flutter_genui/lib/src/catalog/core_widgets/card.dart +++ b/packages/flutter_genui/lib/src/catalog/core_widgets/card.dart @@ -39,6 +39,7 @@ final card = CatalogItem( required dispatchEvent, required context, required dataContext, + required getComponent, }) { final cardData = _CardData.fromMap(data as JsonMap); return Card(child: buildChild(cardData.child)); diff --git a/packages/flutter_genui/lib/src/catalog/core_widgets/check_box.dart b/packages/flutter_genui/lib/src/catalog/core_widgets/check_box.dart index ba65ba9bd..1967a5c9b 100644 --- a/packages/flutter_genui/lib/src/catalog/core_widgets/check_box.dart +++ b/packages/flutter_genui/lib/src/catalog/core_widgets/check_box.dart @@ -46,6 +46,7 @@ final checkBox = CatalogItem( required dispatchEvent, required context, required dataContext, + required getComponent, }) { final checkBoxData = _CheckBoxData.fromMap(data as JsonMap); final labelNotifier = dataContext.subscribeToString(checkBoxData.label); diff --git a/packages/flutter_genui/lib/src/catalog/core_widgets/column.dart b/packages/flutter_genui/lib/src/catalog/core_widgets/column.dart index beeb69f10..60f5cd764 100644 --- a/packages/flutter_genui/lib/src/catalog/core_widgets/column.dart +++ b/packages/flutter_genui/lib/src/catalog/core_widgets/column.dart @@ -109,23 +109,34 @@ final column = CatalogItem( required dispatchEvent, required context, required dataContext, + required getComponent, }) { final columnData = _ColumnData.fromMap(data as JsonMap); return ComponentChildrenBuilder( childrenData: columnData.children, dataContext: dataContext, buildChild: buildChild, - explicitListBuilder: (children) { - return Column( - mainAxisAlignment: _parseMainAxisAlignment( - columnData.distribution, - ), - crossAxisAlignment: _parseCrossAxisAlignment( - columnData.alignment, - ), - children: children, - ); - }, + getComponent: getComponent, + explicitListBuilder: + (childIds, buildChild, getComponent, dataContext) { + return Column( + mainAxisAlignment: _parseMainAxisAlignment( + columnData.distribution, + ), + crossAxisAlignment: _parseCrossAxisAlignment( + columnData.alignment, + ), + children: childIds.map((id) { + final component = getComponent(id); + final weight = component?.weight; + final childWidget = buildChild(id, dataContext); + if (weight != null) { + return Expanded(flex: weight, child: childWidget); + } + return childWidget; + }).toList(), + ); + }, templateListWidgetBuilder: (context, list, componentId, dataBinding) { return Column( mainAxisAlignment: _parseMainAxisAlignment( diff --git a/packages/flutter_genui/lib/src/catalog/core_widgets/date_time_input.dart b/packages/flutter_genui/lib/src/catalog/core_widgets/date_time_input.dart index dcd24cf5a..e5fec80e1 100644 --- a/packages/flutter_genui/lib/src/catalog/core_widgets/date_time_input.dart +++ b/packages/flutter_genui/lib/src/catalog/core_widgets/date_time_input.dart @@ -66,6 +66,7 @@ final dateTimeInput = CatalogItem( required dispatchEvent, required context, required dataContext, + required getComponent, }) { final dateTimeInputData = _DateTimeInputData.fromMap(data as JsonMap); final valueNotifier = dataContext.subscribeToString( diff --git a/packages/flutter_genui/lib/src/catalog/core_widgets/divider.dart b/packages/flutter_genui/lib/src/catalog/core_widgets/divider.dart index 17e07e204..0e78dae04 100644 --- a/packages/flutter_genui/lib/src/catalog/core_widgets/divider.dart +++ b/packages/flutter_genui/lib/src/catalog/core_widgets/divider.dart @@ -39,6 +39,7 @@ final divider = CatalogItem( required dispatchEvent, required context, required dataContext, + required getComponent, }) { final dividerData = _DividerData.fromMap(data as JsonMap); if (dividerData.axis == 'vertical') { diff --git a/packages/flutter_genui/lib/src/catalog/core_widgets/heading.dart b/packages/flutter_genui/lib/src/catalog/core_widgets/heading.dart index f1eb9c2ba..6bd698abe 100644 --- a/packages/flutter_genui/lib/src/catalog/core_widgets/heading.dart +++ b/packages/flutter_genui/lib/src/catalog/core_widgets/heading.dart @@ -43,6 +43,7 @@ final heading = CatalogItem( required dispatchEvent, required context, required dataContext, + required getComponent, }) { final headingData = _HeadingData.fromMap(data as JsonMap); final notifier = dataContext.subscribeToString(headingData.text); diff --git a/packages/flutter_genui/lib/src/catalog/core_widgets/image.dart b/packages/flutter_genui/lib/src/catalog/core_widgets/image.dart index 9db57f8b4..496b79cea 100644 --- a/packages/flutter_genui/lib/src/catalog/core_widgets/image.dart +++ b/packages/flutter_genui/lib/src/catalog/core_widgets/image.dart @@ -69,6 +69,7 @@ final image = CatalogItem( required dispatchEvent, required context, required dataContext, + required getComponent, }) { final imageData = _ImageData.fromMap(data as JsonMap); final notifier = dataContext.subscribeToString(imageData.url); @@ -85,11 +86,14 @@ final image = CatalogItem( } final fit = imageData.fit; + late Widget child; + if (location.startsWith('assets/')) { - return Image.asset(location, fit: fit); + child = Image.asset(location, fit: fit); } else { - return Image.network(location, fit: fit); + child = Image.network(location, fit: fit); } + return SizedBox(width: 150, height: 150, child: child); }, ); }, diff --git a/packages/flutter_genui/lib/src/catalog/core_widgets/list.dart b/packages/flutter_genui/lib/src/catalog/core_widgets/list.dart index 8c7e0095b..056d4e6e7 100644 --- a/packages/flutter_genui/lib/src/catalog/core_widgets/list.dart +++ b/packages/flutter_genui/lib/src/catalog/core_widgets/list.dart @@ -56,6 +56,7 @@ final list = CatalogItem( required dispatchEvent, required context, required dataContext, + required getComponent, }) { final listData = _ListData.fromMap(data as JsonMap); final direction = listData.direction == 'horizontal' @@ -65,13 +66,17 @@ final list = CatalogItem( childrenData: listData.children, dataContext: dataContext, buildChild: buildChild, - explicitListBuilder: (children) { - return ListView( - shrinkWrap: true, - scrollDirection: direction, - children: children, - ); - }, + getComponent: getComponent, + explicitListBuilder: + (childIds, buildChild, getComponent, dataContext) { + return ListView( + shrinkWrap: true, + scrollDirection: direction, + children: childIds + .map((id) => buildChild(id, dataContext)) + .toList(), + ); + }, templateListWidgetBuilder: (context, Map data, componentId, dataBinding) { final values = data.values.toList(); diff --git a/packages/flutter_genui/lib/src/catalog/core_widgets/modal.dart b/packages/flutter_genui/lib/src/catalog/core_widgets/modal.dart index 809a106c3..8710a0f5f 100644 --- a/packages/flutter_genui/lib/src/catalog/core_widgets/modal.dart +++ b/packages/flutter_genui/lib/src/catalog/core_widgets/modal.dart @@ -50,6 +50,7 @@ final modal = CatalogItem( required dispatchEvent, required context, required dataContext, + required getComponent, }) { final modalData = _ModalData.fromMap(data as JsonMap); return buildChild(modalData.entryPointChild); diff --git a/packages/flutter_genui/lib/src/catalog/core_widgets/multiple_choice.dart b/packages/flutter_genui/lib/src/catalog/core_widgets/multiple_choice.dart index 4e1a28dcd..4afc837c3 100644 --- a/packages/flutter_genui/lib/src/catalog/core_widgets/multiple_choice.dart +++ b/packages/flutter_genui/lib/src/catalog/core_widgets/multiple_choice.dart @@ -66,6 +66,7 @@ final multipleChoice = CatalogItem( required dispatchEvent, required context, required dataContext, + required getComponent, }) { final multipleChoiceData = _MultipleChoiceData.fromMap(data as JsonMap); final selectionsNotifier = dataContext.subscribeToObjectArray( diff --git a/packages/flutter_genui/lib/src/catalog/core_widgets/row.dart b/packages/flutter_genui/lib/src/catalog/core_widgets/row.dart index c8c12a932..b122a3268 100644 --- a/packages/flutter_genui/lib/src/catalog/core_widgets/row.dart +++ b/packages/flutter_genui/lib/src/catalog/core_widgets/row.dart @@ -110,19 +110,34 @@ final row = CatalogItem( required dispatchEvent, required context, required dataContext, + required getComponent, }) { final rowData = _RowData.fromMap(data as JsonMap); return ComponentChildrenBuilder( childrenData: rowData.children, dataContext: dataContext, buildChild: buildChild, - explicitListBuilder: (children) { - return Row( - mainAxisAlignment: _parseMainAxisAlignment(rowData.distribution), - crossAxisAlignment: _parseCrossAxisAlignment(rowData.alignment), - children: children, - ); - }, + getComponent: getComponent, + explicitListBuilder: + (childIds, buildChild, getComponent, dataContext) { + return Row( + mainAxisAlignment: _parseMainAxisAlignment( + rowData.distribution, + ), + crossAxisAlignment: _parseCrossAxisAlignment( + rowData.alignment, + ), + children: childIds.map((id) { + final component = getComponent(id); + final weight = component?.weight; + final childWidget = buildChild(id, dataContext); + if (weight != null) { + return Expanded(flex: weight, child: childWidget); + } + return childWidget; + }).toList(), + ); + }, templateListWidgetBuilder: (context, list, componentId, dataBinding) { return Row( mainAxisAlignment: _parseMainAxisAlignment(rowData.distribution), diff --git a/packages/flutter_genui/lib/src/catalog/core_widgets/slider.dart b/packages/flutter_genui/lib/src/catalog/core_widgets/slider.dart index 56ae9bd18..bc4332d93 100644 --- a/packages/flutter_genui/lib/src/catalog/core_widgets/slider.dart +++ b/packages/flutter_genui/lib/src/catalog/core_widgets/slider.dart @@ -54,6 +54,7 @@ final slider = CatalogItem( required dispatchEvent, required context, required dataContext, + required getComponent, }) { final sliderData = _SliderData.fromMap(data as JsonMap); final valueNotifier = dataContext.subscribeToValue( diff --git a/packages/flutter_genui/lib/src/catalog/core_widgets/tabs.dart b/packages/flutter_genui/lib/src/catalog/core_widgets/tabs.dart index 3203d0702..4ec53a44b 100644 --- a/packages/flutter_genui/lib/src/catalog/core_widgets/tabs.dart +++ b/packages/flutter_genui/lib/src/catalog/core_widgets/tabs.dart @@ -49,6 +49,7 @@ final tabs = CatalogItem( required dispatchEvent, required context, required dataContext, + required getComponent, }) { final tabsData = _TabsData.fromMap(data as JsonMap); return DefaultTabController( diff --git a/packages/flutter_genui/lib/src/catalog/core_widgets/text.dart b/packages/flutter_genui/lib/src/catalog/core_widgets/text.dart index 39a4f096b..13ba7c800 100644 --- a/packages/flutter_genui/lib/src/catalog/core_widgets/text.dart +++ b/packages/flutter_genui/lib/src/catalog/core_widgets/text.dart @@ -56,6 +56,7 @@ final text = CatalogItem( required dispatchEvent, required context, required dataContext, + required getComponent, }) { final textData = _TextData.fromMap(data as JsonMap); final notifier = dataContext.subscribeToString(textData.text); diff --git a/packages/flutter_genui/lib/src/catalog/core_widgets/text_field.dart b/packages/flutter_genui/lib/src/catalog/core_widgets/text_field.dart index 993aef3ac..e0a52df37 100644 --- a/packages/flutter_genui/lib/src/catalog/core_widgets/text_field.dart +++ b/packages/flutter_genui/lib/src/catalog/core_widgets/text_field.dart @@ -169,6 +169,7 @@ final textField = CatalogItem( required dispatchEvent, required context, required dataContext, + required getComponent, }) { final textFieldData = _TextFieldData.fromMap(data as JsonMap); final valueRef = textFieldData.text; diff --git a/packages/flutter_genui/lib/src/catalog/core_widgets/video.dart b/packages/flutter_genui/lib/src/catalog/core_widgets/video.dart index 601340db4..c3cd44fa3 100644 --- a/packages/flutter_genui/lib/src/catalog/core_widgets/video.dart +++ b/packages/flutter_genui/lib/src/catalog/core_widgets/video.dart @@ -33,6 +33,7 @@ final video = CatalogItem( required dispatchEvent, required context, required dataContext, + required getComponent, }) { return ConstrainedBox( constraints: const BoxConstraints(maxWidth: 200, maxHeight: 100), diff --git a/packages/flutter_genui/lib/src/catalog/core_widgets/widget_helpers.dart b/packages/flutter_genui/lib/src/catalog/core_widgets/widget_helpers.dart index 8907884c0..d6ffe9985 100644 --- a/packages/flutter_genui/lib/src/catalog/core_widgets/widget_helpers.dart +++ b/packages/flutter_genui/lib/src/catalog/core_widgets/widget_helpers.dart @@ -17,7 +17,13 @@ typedef TemplateListWidgetBuilder = String dataBinding, ); -typedef ExplicitListWidgetBuilder = Widget Function(List children); +typedef ExplicitListWidgetBuilder = + Widget Function( + List childIds, + ChildBuilderCallback buildChild, + GetComponentCallback getComponent, + DataContext dataContext, + ); /// A helper widget to build widgets from component data that contains a list /// of children. @@ -36,6 +42,7 @@ class ComponentChildrenBuilder extends StatelessWidget { required this.childrenData, required this.dataContext, required this.buildChild, + required this.getComponent, required this.explicitListBuilder, required this.templateListWidgetBuilder, super.key, @@ -50,6 +57,9 @@ class ComponentChildrenBuilder extends StatelessWidget { /// The callback to build a child widget. final ChildBuilderCallback buildChild; + /// The callback to get a component's data by ID. + final GetComponentCallback getComponent; + /// The builder for an explicit list of children. final ExplicitListWidgetBuilder explicitListBuilder; @@ -65,7 +75,10 @@ class ComponentChildrenBuilder extends StatelessWidget { if (explicitList != null) { return explicitListBuilder( - explicitList.map((String id) => buildChild(id, dataContext)).toList(), + explicitList, + buildChild, + getComponent, + dataContext, ); } diff --git a/packages/flutter_genui/lib/src/core/genui_surface.dart b/packages/flutter_genui/lib/src/core/genui_surface.dart index 2735e82a6..876a0314e 100644 --- a/packages/flutter_genui/lib/src/core/genui_surface.dart +++ b/packages/flutter_genui/lib/src/core/genui_surface.dart @@ -90,6 +90,7 @@ class _GenUiSurfaceState extends State { dispatchEvent: _dispatchEvent, context: context, dataContext: dataContext, + getComponent: (String componentId) => definition.components[componentId], ); } diff --git a/packages/flutter_genui/lib/src/model/a2ui_schemas.dart b/packages/flutter_genui/lib/src/model/a2ui_schemas.dart index 482ed36de..6069fde6c 100644 --- a/packages/flutter_genui/lib/src/model/a2ui_schemas.dart +++ b/packages/flutter_genui/lib/src/model/a2ui_schemas.dart @@ -178,6 +178,10 @@ class A2uiSchemas { 'This component could be one of many supported types.', properties: { 'id': S.string(), + 'weight': S.integer( + description: + 'Optional layout weight for use in Row/Column children.', + ), 'component': S.object( description: '''A wrapper object that MUST contain exactly one key, which is the name of the component type (e.g., 'Heading'). The value is an object containing the properties for that specific component.''', diff --git a/packages/flutter_genui/lib/src/model/catalog.dart b/packages/flutter_genui/lib/src/model/catalog.dart index 68931ebe7..576ba6aaf 100644 --- a/packages/flutter_genui/lib/src/model/catalog.dart +++ b/packages/flutter_genui/lib/src/model/catalog.dart @@ -69,6 +69,7 @@ class Catalog { required DispatchEventCallback dispatchEvent, required BuildContext context, required DataContext dataContext, + required GetComponentCallback getComponent, }) { final widgetType = widgetData.keys.firstOrNull; final item = items.firstWhereOrNull((item) => item.name == widgetType); @@ -86,6 +87,7 @@ class Catalog { dispatchEvent: dispatchEvent, context: context, dataContext: dataContext, + getComponent: getComponent, ); } diff --git a/packages/flutter_genui/lib/src/model/catalog_item.dart b/packages/flutter_genui/lib/src/model/catalog_item.dart index fb1918b9a..d1d74739d 100644 --- a/packages/flutter_genui/lib/src/model/catalog_item.dart +++ b/packages/flutter_genui/lib/src/model/catalog_item.dart @@ -8,6 +8,9 @@ import 'package:json_schema_builder/json_schema_builder.dart'; import 'data_model.dart'; import 'ui_models.dart'; +/// A callback to get a component definition by its ID. +typedef GetComponentCallback = Component? Function(String componentId); + /// A callback that builds a child widget for a catalog item. typedef ChildBuilderCallback = Widget Function(String id, [DataContext? dataContext]); @@ -34,6 +37,8 @@ typedef CatalogWidgetBuilder = required BuildContext context, // The current data context for this widget. required DataContext dataContext, + // A function to retrieve a component definition by its ID. + required GetComponentCallback getComponent, }); /// Defines a UI layout type, its schema, and how to build its widget. diff --git a/packages/flutter_genui/lib/src/model/ui_models.dart b/packages/flutter_genui/lib/src/model/ui_models.dart index 34ecec828..017b65930 100644 --- a/packages/flutter_genui/lib/src/model/ui_models.dart +++ b/packages/flutter_genui/lib/src/model/ui_models.dart @@ -134,7 +134,11 @@ class UiDefinition { /// A component in the UI. final class Component { /// Creates a [Component]. - const Component({required this.id, required this.componentProperties}); + const Component({ + required this.id, + required this.componentProperties, + this.weight, + }); /// Creates a [Component] from a JSON map. factory Component.fromJson(JsonMap json) { @@ -144,6 +148,7 @@ final class Component { return Component( id: json['id'] as String, componentProperties: json['component'] as JsonMap, + weight: json['weight'] as int?, ); } @@ -153,9 +158,16 @@ final class Component { /// The properties of the component. final JsonMap componentProperties; + /// The weight of the component, used for layout in Row/Column. + final int? weight; + /// Converts this object to a JSON map. JsonMap toJson() { - return {'id': id, 'component': componentProperties}; + return { + 'id': id, + 'component': componentProperties, + if (weight != null) 'weight': weight, + }; } /// The type of the component. @@ -165,12 +177,16 @@ final class Component { bool operator ==(Object other) => other is Component && id == other.id && + weight == other.weight && const DeepCollectionEquality().equals( componentProperties, other.componentProperties, ); @override - int get hashCode => - Object.hash(id, const DeepCollectionEquality().hash(componentProperties)); + int get hashCode => Object.hash( + id, + weight, + const DeepCollectionEquality().hash(componentProperties), + ); } diff --git a/packages/flutter_genui/test/catalog/core_widgets/column_test.dart b/packages/flutter_genui/test/catalog/core_widgets/column_test.dart new file mode 100644 index 000000000..a6f59d2f6 --- /dev/null +++ b/packages/flutter_genui/test/catalog/core_widgets/column_test.dart @@ -0,0 +1,151 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_genui/flutter_genui.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Column widget renders children', (WidgetTester tester) async { + final manager = GenUiManager( + catalog: Catalog([CoreCatalogItems.column, CoreCatalogItems.text]), + configuration: const GenUiConfiguration(), + ); + const surfaceId = 'testSurface'; + final components = [ + const Component( + id: 'column', + componentProperties: { + 'Column': { + 'children': { + 'explicitList': ['text1', 'text2'], + }, + }, + }, + ), + const Component( + id: 'text1', + componentProperties: { + 'Text': { + 'text': {'literalString': 'First'}, + }, + }, + ), + const Component( + id: 'text2', + componentProperties: { + 'Text': { + 'text': {'literalString': 'Second'}, + }, + }, + ), + ]; + manager.handleMessage( + SurfaceUpdate(surfaceId: surfaceId, components: components), + ); + manager.handleMessage( + const BeginRendering(surfaceId: surfaceId, root: 'column'), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: GenUiSurface(host: manager, surfaceId: surfaceId), + ), + ), + ); + + expect(find.text('First'), findsOneWidget); + expect(find.text('Second'), findsOneWidget); + }); + + testWidgets('Column widget applies weight property to children', ( + WidgetTester tester, + ) async { + final manager = GenUiManager( + catalog: Catalog([CoreCatalogItems.column, CoreCatalogItems.text]), + configuration: const GenUiConfiguration(), + ); + const surfaceId = 'testSurface'; + final components = [ + const Component( + id: 'column', + componentProperties: { + 'Column': { + 'children': { + 'explicitList': ['text1', 'text2', 'text3'], + }, + }, + }, + ), + const Component( + id: 'text1', + componentProperties: { + 'Text': { + 'text': {'literalString': 'First'}, + }, + }, + weight: 1, + ), + const Component( + id: 'text2', + componentProperties: { + 'Text': { + 'text': {'literalString': 'Second'}, + }, + }, + weight: 2, + ), + const Component( + id: 'text3', + componentProperties: { + 'Text': { + 'text': {'literalString': 'Third'}, + }, + }, + ), + ]; + manager.handleMessage( + SurfaceUpdate(surfaceId: surfaceId, components: components), + ); + manager.handleMessage( + const BeginRendering(surfaceId: surfaceId, root: 'column'), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: GenUiSurface(host: manager, surfaceId: surfaceId), + ), + ), + ); + + expect(find.text('First'), findsOneWidget); + expect(find.text('Second'), findsOneWidget); + expect(find.text('Third'), findsOneWidget); + + final expandedWidgets = tester + .widgetList(find.byType(Expanded)) + .toList(); + expect(expandedWidgets.length, 2); + + // Check flex values + expect(expandedWidgets[0].flex, 1); + expect(expandedWidgets[1].flex, 2); + + // Check that the correct children are wrapped + expect( + find.ancestor(of: find.text('First'), matching: find.byType(Expanded)), + findsOneWidget, + ); + expect( + find.ancestor(of: find.text('Second'), matching: find.byType(Expanded)), + findsOneWidget, + ); + expect( + find.ancestor(of: find.text('Third'), matching: find.byType(Expanded)), + findsNothing, + ); + }); +} diff --git a/packages/flutter_genui/test/catalog/core_widgets/row_test.dart b/packages/flutter_genui/test/catalog/core_widgets/row_test.dart index 298e4023c..162ad79a8 100644 --- a/packages/flutter_genui/test/catalog/core_widgets/row_test.dart +++ b/packages/flutter_genui/test/catalog/core_widgets/row_test.dart @@ -59,4 +59,93 @@ void main() { expect(find.text('First'), findsOneWidget); expect(find.text('Second'), findsOneWidget); }); + + testWidgets('Row widget applies weight property to children', ( + WidgetTester tester, + ) async { + final manager = GenUiManager( + catalog: Catalog([CoreCatalogItems.row, CoreCatalogItems.text]), + configuration: const GenUiConfiguration(), + ); + const surfaceId = 'testSurface'; + final components = [ + const Component( + id: 'row', + componentProperties: { + 'Row': { + 'children': { + 'explicitList': ['text1', 'text2', 'text3'], + }, + }, + }, + ), + const Component( + id: 'text1', + componentProperties: { + 'Text': { + 'text': {'literalString': 'First'}, + }, + }, + weight: 1, + ), + const Component( + id: 'text2', + componentProperties: { + 'Text': { + 'text': {'literalString': 'Second'}, + }, + }, + weight: 2, + ), + const Component( + id: 'text3', + componentProperties: { + 'Text': { + 'text': {'literalString': 'Third'}, + }, + }, + ), + ]; + manager.handleMessage( + SurfaceUpdate(surfaceId: surfaceId, components: components), + ); + manager.handleMessage( + const BeginRendering(surfaceId: surfaceId, root: 'row'), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: GenUiSurface(host: manager, surfaceId: surfaceId), + ), + ), + ); + + expect(find.text('First'), findsOneWidget); + expect(find.text('Second'), findsOneWidget); + expect(find.text('Third'), findsOneWidget); + + final expandedWidgets = tester + .widgetList(find.byType(Expanded)) + .toList(); + expect(expandedWidgets.length, 2); + + // Check flex values + expect(expandedWidgets[0].flex, 1); + expect(expandedWidgets[1].flex, 2); + + // Check that the correct children are wrapped + expect( + find.ancestor(of: find.text('First'), matching: find.byType(Expanded)), + findsOneWidget, + ); + expect( + find.ancestor(of: find.text('Second'), matching: find.byType(Expanded)), + findsOneWidget, + ); + expect( + find.ancestor(of: find.text('Third'), matching: find.byType(Expanded)), + findsNothing, + ); + }); } diff --git a/packages/flutter_genui/test/catalog_test.dart b/packages/flutter_genui/test/catalog_test.dart index 1ebe40e92..78f5b7a40 100644 --- a/packages/flutter_genui/test/catalog_test.dart +++ b/packages/flutter_genui/test/catalog_test.dart @@ -34,6 +34,7 @@ void main() { dispatchEvent: (UiEvent event) {}, context: context, dataContext: DataContext(DataModel(), '/'), + getComponent: (String componentId) => null, ); }, ), @@ -78,6 +79,7 @@ void main() { dispatchEvent: (UiEvent event) {}, context: context, dataContext: DataContext(DataModel(), '/'), + getComponent: (String componentId) => null, ); expect(widget, isA()); return widget; diff --git a/packages/flutter_genui/test/core/ui_tools_test.dart b/packages/flutter_genui/test/core/ui_tools_test.dart index 810447bbe..379398836 100644 --- a/packages/flutter_genui/test/core/ui_tools_test.dart +++ b/packages/flutter_genui/test/core/ui_tools_test.dart @@ -34,6 +34,7 @@ void main() { required dispatchEvent, required context, required dataContext, + required getComponent, }) { return const Text(''); }, diff --git a/packages/flutter_genui/test/image_test.dart b/packages/flutter_genui/test/image_test.dart index ce4835191..6a4ef906d 100644 --- a/packages/flutter_genui/test/image_test.dart +++ b/packages/flutter_genui/test/image_test.dart @@ -30,6 +30,7 @@ void main() { dispatchEvent: (UiEvent event) {}, context: context, dataContext: DataContext(DataModel(), '/'), + getComponent: (String componentId) => null, ), ), ), diff --git a/pubspec.yaml b/pubspec.yaml index 4f6bd509c..df196bbb2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,3 +21,6 @@ workspace: - examples/travel_app - tool/fix_copyright - tool/test_and_fix + +flutter: + uses-material-design: true From ba77b7973e4296130250a9a718cd1aba27c3884b Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Thu, 30 Oct 2025 16:42:15 -0700 Subject: [PATCH 3/5] Make flexibles be min size --- .../flutter_genui/lib/src/catalog/core_widgets/column.dart | 4 +++- packages/flutter_genui/lib/src/catalog/core_widgets/row.dart | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/flutter_genui/lib/src/catalog/core_widgets/column.dart b/packages/flutter_genui/lib/src/catalog/core_widgets/column.dart index 1fe30eedc..c05235fe9 100644 --- a/packages/flutter_genui/lib/src/catalog/core_widgets/column.dart +++ b/packages/flutter_genui/lib/src/catalog/core_widgets/column.dart @@ -130,12 +130,13 @@ final column = CatalogItem( crossAxisAlignment: _parseCrossAxisAlignment( columnData.alignment, ), + mainAxisSize: MainAxisSize.min, children: childIds.map((id) { final component = getComponent(id); final weight = component?.weight; final childWidget = buildChild(id, dataContext); if (weight != null) { - return Expanded(flex: weight, child: childWidget); + return Flexible(flex: weight, child: childWidget); } return childWidget; }).toList(), @@ -149,6 +150,7 @@ final column = CatalogItem( crossAxisAlignment: _parseCrossAxisAlignment( columnData.alignment, ), + mainAxisSize: MainAxisSize.min, children: [ for (var i = 0; i < list.length; i++) buildChild( diff --git a/packages/flutter_genui/lib/src/catalog/core_widgets/row.dart b/packages/flutter_genui/lib/src/catalog/core_widgets/row.dart index 1f60c042e..b9764d0b9 100644 --- a/packages/flutter_genui/lib/src/catalog/core_widgets/row.dart +++ b/packages/flutter_genui/lib/src/catalog/core_widgets/row.dart @@ -130,12 +130,13 @@ final row = CatalogItem( crossAxisAlignment: _parseCrossAxisAlignment( rowData.alignment, ), + mainAxisSize: MainAxisSize.min, children: childIds.map((id) { final component = getComponent(id); final weight = component?.weight; final childWidget = buildChild(id, dataContext); if (weight != null) { - return Expanded(flex: weight, child: childWidget); + return Flexible(flex: weight, child: childWidget); } return childWidget; }).toList(), @@ -145,6 +146,7 @@ final row = CatalogItem( return Row( mainAxisAlignment: _parseMainAxisAlignment(rowData.distribution), crossAxisAlignment: _parseCrossAxisAlignment(rowData.alignment), + mainAxisSize: MainAxisSize.min, children: [ for (var i = 0; i < list.length; i++) buildChild( From c2c1dba97507084cf1ab88197d8453c2bf8668d0 Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Thu, 30 Oct 2025 16:54:56 -0700 Subject: [PATCH 4/5] Make row and column min size, fix data path version. --- .../lib/src/catalog/core_widgets/column.dart | 32 +++++++++++-------- .../lib/src/catalog/core_widgets/row.dart | 32 +++++++++++-------- .../catalog/core_widgets/widget_helpers.dart | 17 ++++++++++ .../catalog/core_widgets/column_test.dart | 16 +++++----- .../test/catalog/core_widgets/row_test.dart | 16 +++++----- 5 files changed, 71 insertions(+), 42 deletions(-) diff --git a/packages/flutter_genui/lib/src/catalog/core_widgets/column.dart b/packages/flutter_genui/lib/src/catalog/core_widgets/column.dart index c05235fe9..73bf2ad27 100644 --- a/packages/flutter_genui/lib/src/catalog/core_widgets/column.dart +++ b/packages/flutter_genui/lib/src/catalog/core_widgets/column.dart @@ -131,15 +131,16 @@ final column = CatalogItem( columnData.alignment, ), mainAxisSize: MainAxisSize.min, - children: childIds.map((id) { - final component = getComponent(id); - final weight = component?.weight; - final childWidget = buildChild(id, dataContext); - if (weight != null) { - return Flexible(flex: weight, child: childWidget); - } - return childWidget; - }).toList(), + children: childIds + .map( + (id) => buildWeightedChild( + componentId: id, + dataContext: dataContext, + buildChild: buildChild, + getComponent: getComponent, + ), + ) + .toList(), ); }, templateListWidgetBuilder: (context, list, componentId, dataBinding) { @@ -152,11 +153,16 @@ final column = CatalogItem( ), mainAxisSize: MainAxisSize.min, children: [ - for (var i = 0; i < list.length; i++) - buildChild( - componentId, - dataContext.nested(DataPath('$dataBinding[$i]')), + for (var i = 0; i < list.length; i++) ...[ + buildWeightedChild( + componentId: componentId, + dataContext: dataContext.nested( + DataPath('$dataBinding[$i]'), + ), + buildChild: buildChild, + getComponent: getComponent, ), + ], ], ); }, diff --git a/packages/flutter_genui/lib/src/catalog/core_widgets/row.dart b/packages/flutter_genui/lib/src/catalog/core_widgets/row.dart index b9764d0b9..0a9869c6f 100644 --- a/packages/flutter_genui/lib/src/catalog/core_widgets/row.dart +++ b/packages/flutter_genui/lib/src/catalog/core_widgets/row.dart @@ -131,15 +131,16 @@ final row = CatalogItem( rowData.alignment, ), mainAxisSize: MainAxisSize.min, - children: childIds.map((id) { - final component = getComponent(id); - final weight = component?.weight; - final childWidget = buildChild(id, dataContext); - if (weight != null) { - return Flexible(flex: weight, child: childWidget); - } - return childWidget; - }).toList(), + children: childIds + .map( + (id) => buildWeightedChild( + componentId: id, + dataContext: dataContext, + buildChild: buildChild, + getComponent: getComponent, + ), + ) + .toList(), ); }, templateListWidgetBuilder: (context, list, componentId, dataBinding) { @@ -148,11 +149,16 @@ final row = CatalogItem( crossAxisAlignment: _parseCrossAxisAlignment(rowData.alignment), mainAxisSize: MainAxisSize.min, children: [ - for (var i = 0; i < list.length; i++) - buildChild( - componentId, - dataContext.nested(DataPath('$dataBinding[$i]')), + for (var i = 0; i < list.length; i++) ...[ + buildWeightedChild( + componentId: componentId, + dataContext: dataContext.nested( + DataPath('$dataBinding[$i]'), + ), + buildChild: buildChild, + getComponent: getComponent, ), + ], ], ); }, diff --git a/packages/flutter_genui/lib/src/catalog/core_widgets/widget_helpers.dart b/packages/flutter_genui/lib/src/catalog/core_widgets/widget_helpers.dart index e4c46d625..def6c6160 100644 --- a/packages/flutter_genui/lib/src/catalog/core_widgets/widget_helpers.dart +++ b/packages/flutter_genui/lib/src/catalog/core_widgets/widget_helpers.dart @@ -126,3 +126,20 @@ class ComponentChildrenBuilder extends StatelessWidget { return const SizedBox.shrink(); } } + +/// Builds a child widget, wrapping it in a [Flexible] if a weight is provided +/// in the component. +Widget buildWeightedChild({ + required String componentId, + required DataContext dataContext, + required ChildBuilderCallback buildChild, + required GetComponentCallback getComponent, +}) { + final component = getComponent(componentId); + final weight = component?.weight; + final childWidget = buildChild(componentId, dataContext); + if (weight != null) { + return Flexible(flex: weight, child: childWidget); + } + return childWidget; +} diff --git a/packages/flutter_genui/test/catalog/core_widgets/column_test.dart b/packages/flutter_genui/test/catalog/core_widgets/column_test.dart index a6f59d2f6..3a27c9935 100644 --- a/packages/flutter_genui/test/catalog/core_widgets/column_test.dart +++ b/packages/flutter_genui/test/catalog/core_widgets/column_test.dart @@ -125,26 +125,26 @@ void main() { expect(find.text('Second'), findsOneWidget); expect(find.text('Third'), findsOneWidget); - final expandedWidgets = tester - .widgetList(find.byType(Expanded)) + final flexibleWidgets = tester + .widgetList(find.byType(Flexible)) .toList(); - expect(expandedWidgets.length, 2); + expect(flexibleWidgets.length, 2); // Check flex values - expect(expandedWidgets[0].flex, 1); - expect(expandedWidgets[1].flex, 2); + expect(flexibleWidgets[0].flex, 1); + expect(flexibleWidgets[1].flex, 2); // Check that the correct children are wrapped expect( - find.ancestor(of: find.text('First'), matching: find.byType(Expanded)), + find.ancestor(of: find.text('First'), matching: find.byType(Flexible)), findsOneWidget, ); expect( - find.ancestor(of: find.text('Second'), matching: find.byType(Expanded)), + find.ancestor(of: find.text('Second'), matching: find.byType(Flexible)), findsOneWidget, ); expect( - find.ancestor(of: find.text('Third'), matching: find.byType(Expanded)), + find.ancestor(of: find.text('Third'), matching: find.byType(Flexible)), findsNothing, ); }); diff --git a/packages/flutter_genui/test/catalog/core_widgets/row_test.dart b/packages/flutter_genui/test/catalog/core_widgets/row_test.dart index 162ad79a8..6ded202e9 100644 --- a/packages/flutter_genui/test/catalog/core_widgets/row_test.dart +++ b/packages/flutter_genui/test/catalog/core_widgets/row_test.dart @@ -125,26 +125,26 @@ void main() { expect(find.text('Second'), findsOneWidget); expect(find.text('Third'), findsOneWidget); - final expandedWidgets = tester - .widgetList(find.byType(Expanded)) + final flexibleWidgets = tester + .widgetList(find.byType(Flexible)) .toList(); - expect(expandedWidgets.length, 2); + expect(flexibleWidgets.length, 2); // Check flex values - expect(expandedWidgets[0].flex, 1); - expect(expandedWidgets[1].flex, 2); + expect(flexibleWidgets[0].flex, 1); + expect(flexibleWidgets[1].flex, 2); // Check that the correct children are wrapped expect( - find.ancestor(of: find.text('First'), matching: find.byType(Expanded)), + find.ancestor(of: find.text('First'), matching: find.byType(Flexible)), findsOneWidget, ); expect( - find.ancestor(of: find.text('Second'), matching: find.byType(Expanded)), + find.ancestor(of: find.text('Second'), matching: find.byType(Flexible)), findsOneWidget, ); expect( - find.ancestor(of: find.text('Third'), matching: find.byType(Expanded)), + find.ancestor(of: find.text('Third'), matching: find.byType(Flexible)), findsNothing, ); }); From 88ad1115305f5313e0a929eab2d511991aa58f60 Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Thu, 30 Oct 2025 17:09:54 -0700 Subject: [PATCH 5/5] Clean up path spec, use component --- .../lib/src/catalog/core_widgets/column.dart | 10 +++++----- .../lib/src/catalog/core_widgets/row.dart | 10 +++++----- .../lib/src/catalog/core_widgets/widget_helpers.dart | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/flutter_genui/lib/src/catalog/core_widgets/column.dart b/packages/flutter_genui/lib/src/catalog/core_widgets/column.dart index 73bf2ad27..d9f41ad5c 100644 --- a/packages/flutter_genui/lib/src/catalog/core_widgets/column.dart +++ b/packages/flutter_genui/lib/src/catalog/core_widgets/column.dart @@ -133,11 +133,11 @@ final column = CatalogItem( mainAxisSize: MainAxisSize.min, children: childIds .map( - (id) => buildWeightedChild( - componentId: id, + (componentId) => buildWeightedChild( + componentId: componentId, dataContext: dataContext, buildChild: buildChild, - getComponent: getComponent, + component: getComponent(componentId), ), ) .toList(), @@ -157,10 +157,10 @@ final column = CatalogItem( buildWeightedChild( componentId: componentId, dataContext: dataContext.nested( - DataPath('$dataBinding[$i]'), + DataPath('$dataBinding/$i'), ), buildChild: buildChild, - getComponent: getComponent, + component: getComponent(componentId), ), ], ], diff --git a/packages/flutter_genui/lib/src/catalog/core_widgets/row.dart b/packages/flutter_genui/lib/src/catalog/core_widgets/row.dart index 0a9869c6f..ac317ef30 100644 --- a/packages/flutter_genui/lib/src/catalog/core_widgets/row.dart +++ b/packages/flutter_genui/lib/src/catalog/core_widgets/row.dart @@ -133,11 +133,11 @@ final row = CatalogItem( mainAxisSize: MainAxisSize.min, children: childIds .map( - (id) => buildWeightedChild( - componentId: id, + (componentId) => buildWeightedChild( + componentId: componentId, dataContext: dataContext, buildChild: buildChild, - getComponent: getComponent, + component: getComponent(componentId), ), ) .toList(), @@ -153,10 +153,10 @@ final row = CatalogItem( buildWeightedChild( componentId: componentId, dataContext: dataContext.nested( - DataPath('$dataBinding[$i]'), + DataPath('$dataBinding/$i'), ), buildChild: buildChild, - getComponent: getComponent, + component: getComponent(componentId), ), ], ], diff --git a/packages/flutter_genui/lib/src/catalog/core_widgets/widget_helpers.dart b/packages/flutter_genui/lib/src/catalog/core_widgets/widget_helpers.dart index def6c6160..7ccdeb5c4 100644 --- a/packages/flutter_genui/lib/src/catalog/core_widgets/widget_helpers.dart +++ b/packages/flutter_genui/lib/src/catalog/core_widgets/widget_helpers.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import '../../model/catalog_item.dart'; import '../../model/data_model.dart'; +import '../../model/ui_models.dart'; import '../../primitives/logging.dart'; import '../../primitives/simple_items.dart'; @@ -133,9 +134,8 @@ Widget buildWeightedChild({ required String componentId, required DataContext dataContext, required ChildBuilderCallback buildChild, - required GetComponentCallback getComponent, + required Component? component, }) { - final component = getComponent(componentId); final weight = component?.weight; final childWidget = buildChild(componentId, dataContext); if (weight != null) {