From b534cb859f9cfa2392e80b75af943ee4d802cefe Mon Sep 17 00:00:00 2001 From: Jacob Simionato Date: Sun, 27 Jul 2025 08:22:16 +1000 Subject: [PATCH 01/10] Centralize definition of each wiget in CatalogItems. --- pkgs/genui_client/lib/firebase_options.dart | 32 +- pkgs/genui_client/lib/main.dart | 12 +- pkgs/genui_client/lib/src/catalog.dart | 57 ++ pkgs/genui_client/lib/src/catalog_item.dart | 23 + pkgs/genui_client/lib/src/core_catalog.dart | 8 + pkgs/genui_client/lib/src/dynamic_ui.dart | 487 +++++++++--------- pkgs/genui_client/lib/src/ui_models.dart | 15 +- pkgs/genui_client/lib/src/ui_server.dart | 20 +- .../lib/src/widget_tree_llm_adapter.dart | 71 +++ pkgs/genui_client/lib/src/widgets/column.dart | 93 ++++ .../lib/src/widgets/elevated_button.dart | 34 ++ 11 files changed, 595 insertions(+), 257 deletions(-) create mode 100644 pkgs/genui_client/lib/src/catalog.dart create mode 100644 pkgs/genui_client/lib/src/catalog_item.dart create mode 100644 pkgs/genui_client/lib/src/core_catalog.dart create mode 100644 pkgs/genui_client/lib/src/widget_tree_llm_adapter.dart create mode 100644 pkgs/genui_client/lib/src/widgets/column.dart create mode 100644 pkgs/genui_client/lib/src/widgets/elevated_button.dart diff --git a/pkgs/genui_client/lib/firebase_options.dart b/pkgs/genui_client/lib/firebase_options.dart index f4762a166..a4e967f1c 100644 --- a/pkgs/genui_client/lib/firebase_options.dart +++ b/pkgs/genui_client/lib/firebase_options.dart @@ -17,19 +17,13 @@ import 'package:flutter/foundation.dart' class DefaultFirebaseOptions { static FirebaseOptions get currentPlatform { if (kIsWeb) { - throw UnsupportedError( - 'DefaultFirebaseOptions have not been configured for web - ' - 'you can reconfigure this by running the FlutterFire CLI again.', - ); + return web; } switch (defaultTargetPlatform) { case TargetPlatform.android: return android; case TargetPlatform.iOS: - throw UnsupportedError( - 'DefaultFirebaseOptions have not been configured for ios - ' - 'you can reconfigure this by running the FlutterFire CLI again.', - ); + return ios; case TargetPlatform.macOS: return macos; case TargetPlatform.windows: @@ -65,4 +59,24 @@ class DefaultFirebaseOptions { projectId: 'fluttergenui', storageBucket: 'fluttergenui.firebasestorage.app', ); -} + + static const FirebaseOptions web = FirebaseOptions( + apiKey: 'AIzaSyBZwqYvj1Qe2odTlesCrD6tI7ZUzkax5BA', + appId: '1:975757934897:web:818e744e0a7130da0ff010', + messagingSenderId: '975757934897', + projectId: 'fluttergenui', + authDomain: 'fluttergenui.firebaseapp.com', + storageBucket: 'fluttergenui.firebasestorage.app', + measurementId: 'G-D2FN00RSD9', + ); + + static const FirebaseOptions ios = FirebaseOptions( + apiKey: 'AIzaSyCxByPgKNTJc4Gc5N0w2l-I5ffE-dWkVKg', + appId: '1:975757934897:ios:540a1a4eeeb57afb0ff010', + messagingSenderId: '975757934897', + projectId: 'fluttergenui', + storageBucket: 'fluttergenui.firebasestorage.app', + iosBundleId: 'dev.flutter.genui.genuiClient', + ); + +} \ No newline at end of file diff --git a/pkgs/genui_client/lib/main.dart b/pkgs/genui_client/lib/main.dart index a98d3cbb8..19bfefab4 100644 --- a/pkgs/genui_client/lib/main.dart +++ b/pkgs/genui_client/lib/main.dart @@ -6,9 +6,11 @@ import 'package:flutter/material.dart'; import 'firebase_options.dart'; import 'src/ai_client/ai_client.dart'; import 'src/chat_message.dart'; +import 'src/core_catalog.dart'; import 'src/dynamic_ui.dart'; import 'src/ui_models.dart'; import 'src/ui_server.dart'; +import 'src/widget_tree_llm_adapter.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -94,6 +96,7 @@ class _GenUIHomePageState extends State { final _promptController = TextEditingController(); late final ServerConnection _serverConnection; final ScrollController _scrollController = ScrollController(); + final widgetTreeLlmAdapter = WidgetTreeLlmAdapter(coreCatalog); @override void initState() { @@ -152,12 +155,13 @@ class _GenUIHomePageState extends State { setState(() { _connectionStatus = status; if (status == 'Server started.' && _chatHistory.isEmpty) { - _chatHistory.add(const SystemMessage( - text: 'I can create UIs. What should I make for you?')); + // _chatHistory.add(const SystemMessage( + // text: 'I can create UIs. What should I make for you?')); } }); }, aiClient: widget.aiClient, + widgetTreeLlmAdapter: widgetTreeLlmAdapter, ); if (widget.autoStartServer) { @@ -266,8 +270,10 @@ class _GenUIHomePageState extends State { padding: const EdgeInsets.all(16.0), child: DynamicUi( key: message.uiKey, + catalog: widgetTreeLlmAdapter.catalog, surfaceId: message.surfaceId, - definition: message.definition, + definition: UiDefinition.fromMap( + message.definition), onEvent: _handleUiEvent, ), ), diff --git a/pkgs/genui_client/lib/src/catalog.dart b/pkgs/genui_client/lib/src/catalog.dart new file mode 100644 index 000000000..4c9a308e0 --- /dev/null +++ b/pkgs/genui_client/lib/src/catalog.dart @@ -0,0 +1,57 @@ +import 'package:collection/collection.dart'; +import 'package:firebase_ai/firebase_ai.dart'; +import 'package:flutter/material.dart'; + +import 'catalog_item.dart'; + +class Catalog { + Catalog(this.items); + + final List items; + + Widget buildWidget( + Map + data, // The actual deserialized JSON data for this layout + Widget Function(String id) buildChild, + void Function(String widgetId, String eventType, Object? value) + dispatchEvent, + BuildContext context, + ) { + final widgetType = (data['widget'] as Map).keys.first; + final item = items.firstWhereOrNull((item) => item.name == widgetType); + if (item == null) { + print('Item $widgetType was not found in catalog'); + return Container(); + } + + return item.widgetBuilder( + (data['widget'] as Map)[widgetType], + data['id'] as String, + buildChild, + dispatchEvent, + context, + ); + } + + Schema get schema { + // Dynamically build schema properties from supported layouts + final schemaProperties = { + for (var item in items) item.name: item.dataSchema, + }; + final optionalSchemaProperties = [ + for (var item in items) item.name, + ]; + + return Schema.object( + description: + 'Represents a *single* widget in a UI widget tree. This widget could be one of many supported types.', + properties: { + 'id': Schema.string(), + 'widget': Schema.object( + description: + 'The properties of the specific widget that this represents. This is a oneof - only *one* field should be set on this object!', + properties: schemaProperties, + optionalProperties: optionalSchemaProperties), + }); + } +} diff --git a/pkgs/genui_client/lib/src/catalog_item.dart b/pkgs/genui_client/lib/src/catalog_item.dart new file mode 100644 index 000000000..29853af6b --- /dev/null +++ b/pkgs/genui_client/lib/src/catalog_item.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'package:firebase_ai/firebase_ai.dart'; + +typedef CatalogWidgetBuilder = Widget Function( + dynamic data, // The actual deserialized JSON data for this layout + String id, + Widget Function(String id) buildChild, + void Function(String widgetId, String eventType, Object? value) dispatchEvent, + BuildContext context, +); + +/// Defines a UI layout type, its schema, and how to build its widget. +class CatalogItem { + final String name; // The key used in JSON, e.g., 'text_chat_message' + final Schema dataSchema; // The schema definition for this layout's data + final CatalogWidgetBuilder widgetBuilder; + + CatalogItem({ + required this.name, + required this.dataSchema, + required this.widgetBuilder, + }); +} diff --git a/pkgs/genui_client/lib/src/core_catalog.dart b/pkgs/genui_client/lib/src/core_catalog.dart new file mode 100644 index 000000000..31079ce65 --- /dev/null +++ b/pkgs/genui_client/lib/src/core_catalog.dart @@ -0,0 +1,8 @@ +import 'catalog.dart'; +import 'widgets/elevated_button.dart'; +import 'widgets/column.dart'; + +final coreCatalog = Catalog([ + elevatedButtonCatalogItem, + columnCatalogItem, +]); diff --git a/pkgs/genui_client/lib/src/dynamic_ui.dart b/pkgs/genui_client/lib/src/dynamic_ui.dart index 5720e19da..5ba15e3f6 100644 --- a/pkgs/genui_client/lib/src/dynamic_ui.dart +++ b/pkgs/genui_client/lib/src/dynamic_ui.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; + +import 'catalog.dart'; import 'ui_models.dart'; /// A widget that builds a UI dynamically from a JSON-like definition. @@ -8,6 +10,7 @@ import 'ui_models.dart'; class DynamicUi extends StatefulWidget { const DynamicUi({ super.key, + required this.catalog, required this.surfaceId, required this.definition, required this.onEvent, @@ -17,11 +20,13 @@ class DynamicUi extends StatefulWidget { final String surfaceId; /// The initial UI structure. - final Map definition; + final UiDefinition definition; /// A callback for when a user interacts with a widget. final void Function(Map event) onEvent; + final Catalog catalog; + @override State createState() => _DynamicUiState(); } @@ -29,79 +34,73 @@ class DynamicUi extends StatefulWidget { class _DynamicUiState extends State { /// Stores the current props for every widget, keyed by widget ID. /// This allows for efficient state updates. - late final Map> _widgetStates; - final Map _textControllers = {}; - late final UiDefinition _uiDefinition; - - @override - void initState() { - super.initState(); - _initializeState(); - } + // late final Map> _widgetStates; + // final Map _textControllers = {}; + // late final UiDefinition _uiDefinition; /// When the widget is replaced with a new one (e.g., due to a key change), /// we must re-initialize its state. @override void didUpdateWidget(covariant DynamicUi oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.definition != oldWidget.definition) { - _cleanupState(); - _initializeState(); - } + // if (widget.definition != oldWidget.definition) { + // _cleanupState(); + // _initializeState(); + // } } - void _initializeState() { - final definition = Map.from(widget.definition); - final widgets = definition['widgets']; + // void _initializeState() { + // final definition = Map.from(widget.definition); + // final widgets = definition['widgets']; - // The schema defines `widgets` as a list of widget definitions, but this - // class expects `widgets` to be a map from widget ID to widget definition, - // so we convert the list to a map here. - if (widgets is List) { - definition['widgets'] = { - for (final widgetDef in widgets) - if (widgetDef is Map && widgetDef['id'] is String) - widgetDef['id'] as String: widgetDef, - }; - } + // // The schema defines `widgets` as a list of widget definitions, but this + // // class expects `widgets` to be a map from widget ID to widget definition, + // // so we convert the list to a map here. + // if (widgets is List) { + // definition['widgets'] = { + // for (final widgetDef in widgets) + // if (widgetDef is Map && widgetDef['id'] is String) + // widgetDef['id'] as String: widgetDef, + // }; + // } - _uiDefinition = UiDefinition.fromMap(definition); - _widgetStates = {}; - _populateInitialStates(); - } + // _uiDefinition = UiDefinition.fromMap(definition); + // _widgetStates = {}; + // _populateInitialStates(); + // } - void _cleanupState() { - for (var controller in _textControllers.values) { - controller.dispose(); - } - _textControllers.clear(); - _widgetStates.clear(); - } + // void _cleanupState() { + // for (var controller in _textControllers.values) { + // controller.dispose(); + // } + // _textControllers.clear(); + // _widgetStates.clear(); + // } @override void dispose() { - _cleanupState(); + //_cleanupState(); super.dispose(); } /// Traverses the initial UI definition to populate the /// [_widgetStates] map and create TextEditingControllers. - void _populateInitialStates() { - for (final widgetDefEntry in _uiDefinition.widgets.entries) { - final widgetDef = WidgetDefinition.fromMap(widgetDefEntry.value); - final id = widgetDef.id; - // Make a mutable copy - final props = Map.from(widgetDef.props); + // void _populateInitialStates() { + // for (final widgetDefEntry in _uiDefinition.widgets.entries) { + // final widgetDef = WidgetDefinition.fromMap(widgetDefEntry.value); + // final id = widgetDef.id; + // // Make a mutable copy + // final props = Map.from(widgetDef.props); - _widgetStates[id] = props; + // _widgetStates[id] = props; - if (widgetDef.type == 'TextField') { - final textField = UiTextField.fromMap({'props': props}); - final controller = TextEditingController(text: textField.value); - _textControllers[id] = controller; - } - } - } + // if (widgetDef.type == 'TextField') { + // final textField = UiTextField.fromMap({'props': props}); + // final controller = TextEditingController(text: textField.value); + // _textControllers[id] = controller; + // } + // } + // } /// Dispatches an event by calling the public [DynamicUi.onEvent] callback. void _dispatchEvent(String widgetId, String eventType, Object? value) { @@ -117,8 +116,8 @@ class _DynamicUiState extends State { @override Widget build(BuildContext context) { - final rootId = _uiDefinition.root; - if (_uiDefinition.widgets.isEmpty) { + final rootId = widget.definition.root; + if (widget.definition.widgets.isEmpty) { return const SizedBox.shrink(); } return _buildWidget(rootId); @@ -128,203 +127,209 @@ class _DynamicUiState extends State { /// It reads a widget definition and its current state from [_widgetStates] /// and constructs the corresponding Flutter widget. Widget _buildWidget(String widgetId) { - final widgetDefMap = _uiDefinition.widgets[widgetId]; - if (widgetDefMap == null) { - return Text('Unknown widget ID: $widgetId'); - } - final widgetDef = WidgetDefinition.fromMap(widgetDefMap); - final id = widgetDef.id; - final type = widgetDef.type; + return widget.catalog.buildWidget( + widget.definition.widgets[widgetId]! as Map, + _buildWidget, + _dispatchEvent, + context); - // Always get the latest props from our state map. - final props = _widgetStates[id] ?? widgetDef.props; + // final widgetDefMap = _uiDefinition.widgets[widgetId]; + // if (widgetDefMap == null) { + // return Text('Unknown widget ID: $widgetId'); + // } + // final widgetDef = WidgetDefinition.fromMap(widgetDefMap); + // final id = widgetDef.id; + // final type = widgetDef.type; - switch (type) { - case 'Text': - final text = UiText.fromMap({'props': props}); - return Text( - text.data, - style: TextStyle( - fontSize: text.fontSize, - fontWeight: - text.fontWeight == 'bold' ? FontWeight.bold : FontWeight.normal, - ), - ); - case 'TextField': - final textField = UiTextField.fromMap({'props': props}); - final controller = _textControllers[id]!; - return ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: 500, - ), - child: TextField( - controller: controller, - decoration: InputDecoration(hintText: textField.hintText), - obscureText: textField.obscureText, - onChanged: (value) => _dispatchEvent(id, 'onChanged', value), - onSubmitted: (value) => _dispatchEvent(id, 'onSubmitted', value), - ), - ); - case 'Checkbox': - final checkbox = UiCheckbox.fromMap({'props': props}); - if (checkbox.label != null) { - return CheckboxListTile( - title: Text(checkbox.label!), - value: checkbox.value, - onChanged: (value) => _dispatchEvent(id, 'onChanged', value), - controlAffinity: ListTileControlAffinity.leading, - ); - } - return Checkbox( - value: checkbox.value, - onChanged: (value) => _dispatchEvent(id, 'onChanged', value), - ); - case 'Radio': - final radio = UiRadio.fromMap({'props': props}); - void changedCallback(Object? newValue) { - if (newValue == null) return; - _dispatchEvent(id, 'onChanged', newValue); - } + // // Always get the latest props from our state map. + // final props = _widgetStates[id] ?? widgetDef.props; - if (radio.label != null) { - return RadioListTile( - title: Text(radio.label!), - value: radio.value, - // ignore: deprecated_member_use - groupValue: radio.groupValue, - // ignore: deprecated_member_use - onChanged: changedCallback, - ); - } - return Radio( - value: radio.value, - // ignore: deprecated_member_use - groupValue: radio.groupValue, - // ignore: deprecated_member_use - onChanged: changedCallback, - ); - case 'Slider': - final slider = UiSlider.fromMap({'props': props}); - return Slider( - value: slider.value, - min: slider.min, - max: slider.max, - divisions: slider.divisions, - label: slider.value.round().toString(), - onChanged: (value) => _dispatchEvent(id, 'onChanged', value), - ); - case 'Align': - final align = UiAlign.fromMap({'props': props}); - return Align( - alignment: _parseAlignment(align.alignment), - child: align.child != null ? _buildWidget(align.child!) : null, - ); - case 'Column': - final column = UiContainer.fromMap({'props': props}); - return Column( - mainAxisAlignment: _parseMainAxisAlignment(column.mainAxisAlignment), - crossAxisAlignment: - _parseCrossAxisAlignment(column.crossAxisAlignment), - children: (column.children ?? []).map(_buildWidget).toList(), - ); - case 'Row': - final row = UiContainer.fromMap({'props': props}); - return Row( - mainAxisAlignment: _parseMainAxisAlignment(row.mainAxisAlignment), - crossAxisAlignment: _parseCrossAxisAlignment(row.crossAxisAlignment), - children: (row.children ?? []).map(_buildWidget).toList(), - ); - case 'ElevatedButton': - final button = UiElevatedButton.fromMap({'props': props}); - return ElevatedButton( - onPressed: () => _dispatchEvent(id, 'onTap', null), - child: button.child != null ? _buildWidget(button.child!) : null, - ); - case 'Padding': - final padding = UiPadding.fromMap({'props': props}); - return Padding( - padding: _parseEdgeInsets(padding.padding), - child: padding.child != null ? _buildWidget(padding.child!) : null, - ); - default: - return Text('Unknown widget type: $type'); - } + // switch (type) { + // case 'Text': + // final text = UiText.fromMap({'props': props}); + // return Text( + // text.data, + // style: TextStyle( + // fontSize: text.fontSize, + // fontWeight: + // text.fontWeight == 'bold' ? FontWeight.bold : FontWeight.normal, + // ), + // ); + // case 'TextField': + // final textField = UiTextField.fromMap({'props': props}); + // final controller = _textControllers[id]!; + // return ConstrainedBox( + // constraints: const BoxConstraints( + // maxWidth: 500, + // ), + // child: TextField( + // controller: controller, + // decoration: InputDecoration(hintText: textField.hintText), + // obscureText: textField.obscureText, + // onChanged: (value) => _dispatchEvent(id, 'onChanged', value), + // onSubmitted: (value) => _dispatchEvent(id, 'onSubmitted', value), + // ), + // ); + // case 'Checkbox': + // final checkbox = UiCheckbox.fromMap({'props': props}); + // if (checkbox.label != null) { + // return CheckboxListTile( + // title: Text(checkbox.label!), + // value: checkbox.value, + // onChanged: (value) => _dispatchEvent(id, 'onChanged', value), + // controlAffinity: ListTileControlAffinity.leading, + // ); + // } + // return Checkbox( + // value: checkbox.value, + // onChanged: (value) => _dispatchEvent(id, 'onChanged', value), + // ); + // case 'Radio': + // final radio = UiRadio.fromMap({'props': props}); + // void changedCallback(Object? newValue) { + // if (newValue == null) return; + // _dispatchEvent(id, 'onChanged', newValue); + // } + + // if (radio.label != null) { + // return RadioListTile( + // title: Text(radio.label!), + // value: radio.value, + // // ignore: deprecated_member_use + // groupValue: radio.groupValue, + // // ignore: deprecated_member_use + // onChanged: changedCallback, + // ); + // } + // return Radio( + // value: radio.value, + // // ignore: deprecated_member_use + // groupValue: radio.groupValue, + // // ignore: deprecated_member_use + // onChanged: changedCallback, + // ); + // case 'Slider': + // final slider = UiSlider.fromMap({'props': props}); + // return Slider( + // value: slider.value, + // min: slider.min, + // max: slider.max, + // divisions: slider.divisions, + // label: slider.value.round().toString(), + // onChanged: (value) => _dispatchEvent(id, 'onChanged', value), + // ); + // case 'Align': + // final align = UiAlign.fromMap({'props': props}); + // return Align( + // alignment: _parseAlignment(align.alignment), + // child: align.child != null ? _buildWidget(align.child!) : null, + // ); + // case 'Column': + // final column = UiContainer.fromMap({'props': props}); + // return Column( + // mainAxisAlignment: _parseMainAxisAlignment(column.mainAxisAlignment), + // crossAxisAlignment: + // _parseCrossAxisAlignment(column.crossAxisAlignment), + // children: (column.children ?? []).map(_buildWidget).toList(), + // ); + // case 'Row': + // final row = UiContainer.fromMap({'props': props}); + // return Row( + // mainAxisAlignment: _parseMainAxisAlignment(row.mainAxisAlignment), + // crossAxisAlignment: _parseCrossAxisAlignment(row.crossAxisAlignment), + // children: (row.children ?? []).map(_buildWidget).toList(), + // ); + // case 'ElevatedButton': + // final button = UiElevatedButton.fromMap({'props': props}); + // return ElevatedButton( + // onPressed: () => _dispatchEvent(id, 'onTap', null), + // child: button.child != null ? _buildWidget(button.child!) : null, + // ); + // case 'Padding': + // final padding = UiPadding.fromMap({'props': props}); + // return Padding( + // padding: _parseEdgeInsets(padding.padding), + // child: padding.child != null ? _buildWidget(padding.child!) : null, + // ); + // default: + // return Text('Unknown widget type: $type'); + // } } // --- Parsing Helper Functions --- /// Parses a [UiEdgeInsets] object into a Flutter [EdgeInsets] object. - EdgeInsets _parseEdgeInsets(UiEdgeInsets edgeInsets) { - return EdgeInsets.fromLTRB( - edgeInsets.left, - edgeInsets.top, - edgeInsets.right, - edgeInsets.bottom, - ); - } + // EdgeInsets _parseEdgeInsets(UiEdgeInsets edgeInsets) { + // return EdgeInsets.fromLTRB( + // edgeInsets.left, + // edgeInsets.top, + // edgeInsets.right, + // edgeInsets.bottom, + // ); + // } - /// Parses a string representation of an alignment into a Flutter - /// [Alignment] object. - Alignment _parseAlignment(String? alignment) { - switch (alignment) { - case 'topLeft': - return Alignment.topLeft; - case 'topCenter': - return Alignment.topCenter; - case 'topRight': - return Alignment.topRight; - case 'centerLeft': - return Alignment.centerLeft; - case 'center': - return Alignment.center; - case 'centerRight': - return Alignment.centerRight; - case 'bottomLeft': - return Alignment.bottomLeft; - case 'bottomCenter': - return Alignment.bottomCenter; - case 'bottomRight': - return Alignment.bottomRight; - default: - return Alignment.center; - } - } + // /// Parses a string representation of an alignment into a Flutter + // /// [Alignment] object. + // Alignment _parseAlignment(String? alignment) { + // switch (alignment) { + // case 'topLeft': + // return Alignment.topLeft; + // case 'topCenter': + // return Alignment.topCenter; + // case 'topRight': + // return Alignment.topRight; + // case 'centerLeft': + // return Alignment.centerLeft; + // case 'center': + // return Alignment.center; + // case 'centerRight': + // return Alignment.centerRight; + // case 'bottomLeft': + // return Alignment.bottomLeft; + // case 'bottomCenter': + // return Alignment.bottomCenter; + // case 'bottomRight': + // return Alignment.bottomRight; + // default: + // return Alignment.center; + // } + // } - /// Parses a string representation of a main axis alignment into a Flutter - /// [MainAxisAlignment] object. - MainAxisAlignment _parseMainAxisAlignment(String? alignment) { - switch (alignment) { - case 'start': - return MainAxisAlignment.start; - case 'center': - return MainAxisAlignment.center; - case 'end': - return MainAxisAlignment.end; - case 'spaceBetween': - return MainAxisAlignment.spaceBetween; - case 'spaceAround': - return MainAxisAlignment.spaceAround; - case 'spaceEvenly': - return MainAxisAlignment.spaceEvenly; - default: - return MainAxisAlignment.start; - } - } + // /// Parses a string representation of a main axis alignment into a Flutter + // /// [MainAxisAlignment] object. + // MainAxisAlignment _parseMainAxisAlignment(String? alignment) { + // switch (alignment) { + // case 'start': + // return MainAxisAlignment.start; + // case 'center': + // return MainAxisAlignment.center; + // case 'end': + // return MainAxisAlignment.end; + // case 'spaceBetween': + // return MainAxisAlignment.spaceBetween; + // case 'spaceAround': + // return MainAxisAlignment.spaceAround; + // case 'spaceEvenly': + // return MainAxisAlignment.spaceEvenly; + // default: + // return MainAxisAlignment.start; + // } + // } /// Parses a string representation of a cross axis alignment into a Flutter /// [CrossAxisAlignment] object. - CrossAxisAlignment _parseCrossAxisAlignment(String? alignment) { - switch (alignment) { - case 'start': - return CrossAxisAlignment.start; - case 'center': - return CrossAxisAlignment.center; - case 'end': - return CrossAxisAlignment.end; - case 'stretch': - return CrossAxisAlignment.stretch; - default: - return CrossAxisAlignment.center; - } - } + // CrossAxisAlignment _parseCrossAxisAlignment(String? alignment) { + // switch (alignment) { + // case 'start': + // return CrossAxisAlignment.start; + // case 'center': + // return CrossAxisAlignment.center; + // case 'end': + // return CrossAxisAlignment.end; + // case 'stretch': + // return CrossAxisAlignment.stretch; + // default: + // return CrossAxisAlignment.center; + // } + // } } diff --git a/pkgs/genui_client/lib/src/ui_models.dart b/pkgs/genui_client/lib/src/ui_models.dart index a37dc1363..ee6df58f4 100644 --- a/pkgs/genui_client/lib/src/ui_models.dart +++ b/pkgs/genui_client/lib/src/ui_models.dart @@ -82,8 +82,19 @@ extension type UiDefinition.fromMap(Map _json) { String get root => _json['root'] as String; /// A map of all widget definitions in the UI, keyed by their ID. - Map> get widgets => - (_json['widgets'] as Map).cast>(); + Map get widgets { + print('JSON for widgets is $_json'); + + final widgetById = {}; + + for (final widget in (_json['widgets'] as List)) { + var typedWidget = widget as Map; + widgetById[typedWidget['id'] as String] = + typedWidget as Map; + } + + return widgetById; + } } /// A data object that represents a single widget definition. diff --git a/pkgs/genui_client/lib/src/ui_server.dart b/pkgs/genui_client/lib/src/ui_server.dart index 32e7d58fa..61004bb3e 100644 --- a/pkgs/genui_client/lib/src/ui_server.dart +++ b/pkgs/genui_client/lib/src/ui_server.dart @@ -2,11 +2,12 @@ import 'dart:async'; import 'package:firebase_ai/firebase_ai.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import 'ai_client/ai_client.dart'; import 'event_debouncer.dart'; import 'ui_models.dart'; -import 'ui_schema.dart'; +import 'widget_tree_llm_adapter.dart'; /// A callback to set the initial UI definition. @visibleForTesting @@ -72,6 +73,7 @@ class StreamServerConnection implements ServerConnection { required this.onError, required this.onStatusUpdate, required this.onTextResponse, + required this.widgetTreeLlmAdapter, AiClient? aiClient, }) : _aiClient = aiClient ?? AiClient( @@ -87,6 +89,8 @@ class StreamServerConnection implements ServerConnection { /// A callback invoked when the server sends a complete new UI definition. final SetUiCallback onSetUi; + final WidgetTreeLlmAdapter widgetTreeLlmAdapter; + /// A callback invoked when the server sends partial updates to the current /// UI. final UpdateUiCallback onUpdateUi; @@ -151,6 +155,7 @@ class StreamServerConnection implements ServerConnection { aiClient: _aiClient, requests: _requestsController.stream, responses: _responsesController, + widgetTreeLlmAdapter: widgetTreeLlmAdapter, )); _requestsController.add({'method': 'ping', 'params': {}}); @@ -212,6 +217,7 @@ ServerConnection createStreamServerConnection({ required ErrorCallback onError, required StatusUpdateCallback onStatusUpdate, required TextResponseCallback onTextResponse, + required WidgetTreeLlmAdapter widgetTreeLlmAdapter, AiClient? aiClient, }) { return StreamServerConnection( @@ -221,6 +227,7 @@ ServerConnection createStreamServerConnection({ onError: onError, onStatusUpdate: onStatusUpdate, onTextResponse: onTextResponse, + widgetTreeLlmAdapter: widgetTreeLlmAdapter, aiClient: aiClient, ); } @@ -236,6 +243,7 @@ Future runUiServer({ required AiClient aiClient, required Stream> requests, required StreamController> responses, + required WidgetTreeLlmAdapter widgetTreeLlmAdapter, }) async { final masterConversation = []; final conversationsBySurfaceId = >{}; @@ -246,7 +254,14 @@ Future runUiServer({ try { final response = await aiClient.generateContent( conversation, - flutterUiDefinition, + widgetTreeLlmAdapter.outputSchema, + systemInstruction: Content.system( + '''You are a helpful assistant who figures out what the user wants to do and then helps suggest options so they can develop a plan and find relevant information. + + The user will ask questions, and you will respond by generating appropriate UI elements. Typically, you will first elicit more information to understand the user's needs, then you will start displaying information and the user's plans. + + For example, the user may say "I want to plan a trip to Mexico". You will first ask some questions by displaying a combination of UI elements, such as a slider to choose budget, options showing activity preferences etc. Then you will walk the user through choosing a hotel, flight and accomodation. + '''), ); if (response == null) { return; @@ -363,4 +378,5 @@ typedef ServerConnectionFactory = ServerConnection Function({ required StatusUpdateCallback onStatusUpdate, required TextResponseCallback onTextResponse, AiClient? aiClient, + required WidgetTreeLlmAdapter widgetTreeLlmAdapter, }); diff --git a/pkgs/genui_client/lib/src/widget_tree_llm_adapter.dart b/pkgs/genui_client/lib/src/widget_tree_llm_adapter.dart new file mode 100644 index 000000000..6882cf396 --- /dev/null +++ b/pkgs/genui_client/lib/src/widget_tree_llm_adapter.dart @@ -0,0 +1,71 @@ +import 'package:firebase_ai/firebase_ai.dart'; + +import 'catalog.dart'; + +class WidgetTreeLlmAdapter { + WidgetTreeLlmAdapter(this.catalog); + + final Catalog catalog; + + /// A schema for defining a simple UI tree to be rendered by Flutter. + /// + /// This schema is a Dart conversion of a more complex JSON schema. + /// Due to limitations in the Dart `Schema` builder API (specifically the lack + /// of support for discriminated unions or `anyOf`), this conversion makes a + /// practical compromise. + /// + /// It strictly enforces the structure of the `root` object, requiring `id` + /// and `type` for every widget in the `widgets` list. The `props` field + /// within each widget is defined as a `Schema.object` with all possible + /// properties for all widget types. The application logic should validate the + /// contents of `props` based on the widget's `type`. + /// + /// This approach ensures that the fundamental structure of the UI definition + /// is always valid according to the schema. + Schema get outputSchema => Schema.object( + properties: { + 'responseText': Schema.string( + description: + 'The text response to the user query. This should be used ' + 'when the query is fully satisfied and no more information is ' + 'needed.', + ), + 'actions': Schema.array( + description: + 'A list of actions to be performed on the UI surfaces.', + items: Schema.object( + properties: { + 'action': Schema.enumString( + description: 'The action to perform on the UI surface.', + enumValues: ['add', 'update', 'delete'], + ), + 'surfaceId': Schema.string( + description: + 'The ID of the surface to perform the action on. For the ' + '`add` action, this will be a new surface ID. For `update` and ' + '`delete`, this will be an existing surface ID.', + ), + 'definition': Schema.object( + properties: { + 'root': Schema.string( + description: 'The ID of the root widget.', + ), + 'widgets': Schema.array( + items: catalog.schema, + description: 'A list of widget definitions.', + ), + }, + description: + 'A schema for defining a simple UI tree to be rendered by ' + 'Flutter.', + ), + }, + optionalProperties: ['surfaceId', 'definition'], + ), + ), + }, + description: 'A schema for defining a simple UI tree to be rendered by ' + 'Flutter.', + optionalProperties: ['actions', 'responseText'], + ); +} diff --git a/pkgs/genui_client/lib/src/widgets/column.dart b/pkgs/genui_client/lib/src/widgets/column.dart new file mode 100644 index 000000000..5e419287b --- /dev/null +++ b/pkgs/genui_client/lib/src/widgets/column.dart @@ -0,0 +1,93 @@ +import 'package:firebase_ai/firebase_ai.dart'; +import 'package:flutter/material.dart'; + +import '../catalog_item.dart'; + +final _schema = Schema.object( + properties: { + 'mainAxisAlignment': Schema.enumString( + description: 'How children are aligned on the main axis. ' + 'See Flutter\'s MainAxisAlignment for values.', + enumValues: [ + 'start', + 'center', + 'end', + 'spaceBetween', + 'spaceAround', + 'spaceEvenly', + ], + ), + 'crossAxisAlignment': Schema.enumString( + description: 'How children are aligned on the cross axis. ' + 'See Flutter\'s CrossAxisAlignment for values.', + enumValues: [ + 'start', + 'center', + 'end', + 'stretch', + 'baseline', + ], + ), + 'children': Schema.array( + items: Schema.string(), + description: 'A list of widget IDs for the children.', + ), + }, +); + +MainAxisAlignment _parseMainAxisAlignment(String? alignment) { + switch (alignment) { + case 'start': + return MainAxisAlignment.start; + case 'center': + return MainAxisAlignment.center; + case 'end': + return MainAxisAlignment.end; + case 'spaceBetween': + return MainAxisAlignment.spaceBetween; + case 'spaceAround': + return MainAxisAlignment.spaceAround; + case 'spaceEvenly': + return MainAxisAlignment.spaceEvenly; + default: + return MainAxisAlignment.start; + } +} + +CrossAxisAlignment _parseCrossAxisAlignment(String? alignment) { + switch (alignment) { + case 'start': + return CrossAxisAlignment.start; + case 'center': + return CrossAxisAlignment.center; + case 'end': + return CrossAxisAlignment.end; + case 'stretch': + return CrossAxisAlignment.stretch; + default: + return CrossAxisAlignment.center; + } +} + +Widget _builder( + dynamic data, + String id, + Widget Function(String id) buildChild, + void Function(String widgetId, String eventType, Object? value) dispatchEvent, + BuildContext context, +) { + final children = (data['children'] as List).cast(); + return Column( + mainAxisAlignment: + _parseMainAxisAlignment(data['mainAxisAlignment'] as String?), + crossAxisAlignment: + _parseCrossAxisAlignment(data['crossAxisAlignment'] as String?), + children: children.map(buildChild).toList(), + ); +} + +final columnCatalogItem = CatalogItem( + name: 'Column', + dataSchema: _schema, + widgetBuilder: _builder, +); diff --git a/pkgs/genui_client/lib/src/widgets/elevated_button.dart b/pkgs/genui_client/lib/src/widgets/elevated_button.dart new file mode 100644 index 000000000..3059c88df --- /dev/null +++ b/pkgs/genui_client/lib/src/widgets/elevated_button.dart @@ -0,0 +1,34 @@ +import 'package:firebase_ai/firebase_ai.dart'; +import 'package:flutter/material.dart'; + +import '../catalog_item.dart'; + +final _schema = Schema.object( + properties: { + 'child': Schema.string( + description: 'The ID of a child widget.', + ), + }, +); + +Widget _builder( + dynamic data, // The actual deserialized JSON data for this layout + String id, + Widget Function(String id) buildChild, + void Function(String widgetId, String eventType, Object? value) + dispatchEvent, + BuildContext context) { + /// The ID of the child widget to display inside the button. + final childId = data['child'] as String; + final child = buildChild(childId); + return ElevatedButton( + onPressed: () => dispatchEvent(id, 'onTap', null), + child: child, + ); +} + +final elevatedButtonCatalogItem = CatalogItem( + name: 'elevated_button', + dataSchema: _schema, + widgetBuilder: _builder, +); From 9dbcf939d22b1d76772fc3e0e73df8e281180113 Mon Sep 17 00:00:00 2001 From: Jacob Simionato Date: Sun, 27 Jul 2025 10:11:53 +1000 Subject: [PATCH 02/10] Add text widget --- pkgs/genui_client/lib/src/core_catalog.dart | 2 ++ pkgs/genui_client/lib/src/widgets/text.dart | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 pkgs/genui_client/lib/src/widgets/text.dart diff --git a/pkgs/genui_client/lib/src/core_catalog.dart b/pkgs/genui_client/lib/src/core_catalog.dart index 31079ce65..49c22a291 100644 --- a/pkgs/genui_client/lib/src/core_catalog.dart +++ b/pkgs/genui_client/lib/src/core_catalog.dart @@ -1,8 +1,10 @@ import 'catalog.dart'; import 'widgets/elevated_button.dart'; import 'widgets/column.dart'; +import 'widgets/text.dart'; final coreCatalog = Catalog([ elevatedButtonCatalogItem, columnCatalogItem, + text, ]); diff --git a/pkgs/genui_client/lib/src/widgets/text.dart b/pkgs/genui_client/lib/src/widgets/text.dart new file mode 100644 index 000000000..887eae148 --- /dev/null +++ b/pkgs/genui_client/lib/src/widgets/text.dart @@ -0,0 +1,20 @@ +import 'package:firebase_ai/firebase_ai.dart'; +import 'package:flutter/material.dart'; +import '../catalog_item.dart'; + +final text = CatalogItem( + name: 'Text', + dataSchema: Schema.object( + properties: { + 'text': Schema.string( + description: 'The text to display.', + ), + }, + ), + widgetBuilder: (data, id, buildChild, dispatchEvent, context) { + return Text( + data['text'] as String, + style: Theme.of(context).textTheme.bodyMedium, + ); + }, +); From 6674f8442231931d2da63d2d5f05c30bd8bc0ccb Mon Sep 17 00:00:00 2001 From: Jacob Simionato Date: Sun, 27 Jul 2025 10:13:43 +1000 Subject: [PATCH 03/10] Add iOS support --- pkgs/genui_client/firebase.json | 2 +- pkgs/genui_client/ios/Podfile.lock | 130 ++++++++++++++++++ .../ios/Runner.xcodeproj/project.pbxproj | 116 ++++++++++++++++ .../contents.xcworkspacedata | 3 + .../ios/Runner/GoogleService-Info.plist | 30 ++++ pkgs/genui_client/macos/Podfile.lock | 8 +- 6 files changed, 284 insertions(+), 5 deletions(-) create mode 100644 pkgs/genui_client/ios/Podfile.lock create mode 100644 pkgs/genui_client/ios/Runner/GoogleService-Info.plist diff --git a/pkgs/genui_client/firebase.json b/pkgs/genui_client/firebase.json index 9e5ccdf7c..da9341606 100644 --- a/pkgs/genui_client/firebase.json +++ b/pkgs/genui_client/firebase.json @@ -1 +1 @@ -{"flutter":{"platforms":{"dart":{"lib/firebase_options.dart":{"projectId":"fluttergenui","configurations":{"android":"1:975757934897:android:1735467a70fdea1d0ff010","macos":"1:975757934897:ios:540a1a4eeeb57afb0ff010"}}},"android":{"default":{"projectId":"fluttergenui","appId":"1:975757934897:android:1735467a70fdea1d0ff010","fileOutput":"android/app/google-services.json"}},"macos":{"default":{"projectId":"fluttergenui","appId":"1:975757934897:ios:540a1a4eeeb57afb0ff010","uploadDebugSymbols":false,"fileOutput":"macos/Runner/GoogleService-Info.plist"}}}}} \ No newline at end of file +{"flutter":{"platforms":{"dart":{"lib/firebase_options.dart":{"projectId":"fluttergenui","configurations":{"android":"1:975757934897:android:1735467a70fdea1d0ff010","ios":"1:975757934897:ios:540a1a4eeeb57afb0ff010","macos":"1:975757934897:ios:540a1a4eeeb57afb0ff010","web":"1:975757934897:web:818e744e0a7130da0ff010"}}},"android":{"default":{"projectId":"fluttergenui","appId":"1:975757934897:android:1735467a70fdea1d0ff010","fileOutput":"android/app/google-services.json"}},"macos":{"default":{"projectId":"fluttergenui","appId":"1:975757934897:ios:540a1a4eeeb57afb0ff010","uploadDebugSymbols":false,"fileOutput":"macos/Runner/GoogleService-Info.plist"}},"ios":{"default":{"projectId":"fluttergenui","appId":"1:975757934897:ios:540a1a4eeeb57afb0ff010","uploadDebugSymbols":false,"fileOutput":"ios/Runner/GoogleService-Info.plist"}}}}} \ No newline at end of file diff --git a/pkgs/genui_client/ios/Podfile.lock b/pkgs/genui_client/ios/Podfile.lock new file mode 100644 index 000000000..a72a3436e --- /dev/null +++ b/pkgs/genui_client/ios/Podfile.lock @@ -0,0 +1,130 @@ +PODS: + - AppCheckCore (11.2.0): + - GoogleUtilities/Environment (~> 8.0) + - GoogleUtilities/UserDefaults (~> 8.0) + - PromisesObjC (~> 2.4) + - Firebase/Auth (11.15.0): + - Firebase/CoreOnly + - FirebaseAuth (~> 11.15.0) + - Firebase/CoreOnly (11.15.0): + - FirebaseCore (~> 11.15.0) + - firebase_app_check (0.3.2-10): + - Firebase/CoreOnly (~> 11.15.0) + - firebase_core + - FirebaseAppCheck (~> 11.15.0) + - Flutter + - firebase_auth (5.7.0): + - Firebase/Auth (= 11.15.0) + - firebase_core + - Flutter + - firebase_core (3.15.2): + - Firebase/CoreOnly (= 11.15.0) + - Flutter + - FirebaseAppCheck (11.15.0): + - AppCheckCore (~> 11.0) + - FirebaseAppCheckInterop (~> 11.0) + - FirebaseCore (~> 11.15.0) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/UserDefaults (~> 8.1) + - FirebaseAppCheckInterop (11.15.0) + - FirebaseAuth (11.15.0): + - FirebaseAppCheckInterop (~> 11.0) + - FirebaseAuthInterop (~> 11.0) + - FirebaseCore (~> 11.15.0) + - FirebaseCoreExtension (~> 11.15.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/Environment (~> 8.1) + - GTMSessionFetcher/Core (< 5.0, >= 3.4) + - RecaptchaInterop (~> 101.0) + - FirebaseAuthInterop (11.15.0) + - FirebaseCore (11.15.0): + - FirebaseCoreInternal (~> 11.15.0) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/Logger (~> 8.1) + - FirebaseCoreExtension (11.15.0): + - FirebaseCore (~> 11.15.0) + - FirebaseCoreInternal (11.15.0): + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - Flutter (1.0.0) + - GoogleUtilities/AppDelegateSwizzler (8.1.0): + - GoogleUtilities/Environment + - GoogleUtilities/Logger + - GoogleUtilities/Network + - GoogleUtilities/Privacy + - GoogleUtilities/Environment (8.1.0): + - GoogleUtilities/Privacy + - GoogleUtilities/Logger (8.1.0): + - GoogleUtilities/Environment + - GoogleUtilities/Privacy + - GoogleUtilities/Network (8.1.0): + - GoogleUtilities/Logger + - "GoogleUtilities/NSData+zlib" + - GoogleUtilities/Privacy + - GoogleUtilities/Reachability + - "GoogleUtilities/NSData+zlib (8.1.0)": + - GoogleUtilities/Privacy + - GoogleUtilities/Privacy (8.1.0) + - GoogleUtilities/Reachability (8.1.0): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GoogleUtilities/UserDefaults (8.1.0): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GTMSessionFetcher/Core (4.5.0) + - PromisesObjC (2.4.0) + - RecaptchaInterop (101.0.0) + +DEPENDENCIES: + - firebase_app_check (from `.symlinks/plugins/firebase_app_check/ios`) + - firebase_auth (from `.symlinks/plugins/firebase_auth/ios`) + - firebase_core (from `.symlinks/plugins/firebase_core/ios`) + - Flutter (from `Flutter`) + +SPEC REPOS: + trunk: + - AppCheckCore + - Firebase + - FirebaseAppCheck + - FirebaseAppCheckInterop + - FirebaseAuth + - FirebaseAuthInterop + - FirebaseCore + - FirebaseCoreExtension + - FirebaseCoreInternal + - GoogleUtilities + - GTMSessionFetcher + - PromisesObjC + - RecaptchaInterop + +EXTERNAL SOURCES: + firebase_app_check: + :path: ".symlinks/plugins/firebase_app_check/ios" + firebase_auth: + :path: ".symlinks/plugins/firebase_auth/ios" + firebase_core: + :path: ".symlinks/plugins/firebase_core/ios" + Flutter: + :path: Flutter + +SPEC CHECKSUMS: + AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f + Firebase: d99ac19b909cd2c548339c2241ecd0d1599ab02e + firebase_app_check: b0f1c33acf8b7c695f4ac16cdb763feac8f0f7f5 + firebase_auth: 5342db41af2ba5ed32a6177d9e326eecbebda912 + firebase_core: 99a37263b3c27536063a7b601d9e2a49400a433c + FirebaseAppCheck: 4574d7180be2a8b514f588099fc5262f032a92c7 + FirebaseAppCheckInterop: 06fe5a3799278ae4667e6c432edd86b1030fa3df + FirebaseAuth: a6575e5fbf46b046c58dc211a28a5fbdd8d4c83b + FirebaseAuthInterop: 7087d7a4ee4bc4de019b2d0c240974ed5d89e2fd + FirebaseCore: efb3893e5b94f32b86e331e3bd6dadf18b66568e + FirebaseCoreExtension: edbd30474b5ccf04e5f001470bdf6ea616af2435 + FirebaseCoreInternal: 9afa45b1159304c963da48addb78275ef701c6b4 + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 + GTMSessionFetcher: fc75fc972958dceedee61cb662ae1da7a83a91cf + PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 + RecaptchaInterop: 11e0b637842dfb48308d242afc3f448062325aba + +PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e + +COCOAPODS: 1.15.2 diff --git a/pkgs/genui_client/ios/Runner.xcodeproj/project.pbxproj b/pkgs/genui_client/ios/Runner.xcodeproj/project.pbxproj index e4b9b1bd9..6a9ccc1c9 100644 --- a/pkgs/genui_client/ios/Runner.xcodeproj/project.pbxproj +++ b/pkgs/genui_client/ios/Runner.xcodeproj/project.pbxproj @@ -7,7 +7,10 @@ objects = { /* Begin PBXBuildFile section */ + 069003F4D939E7E87A488C82 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F03C0C3077B49238C29CE773 /* Pods_RunnerTests.framework */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 1E853A3178011C9ADC9100E6 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A8E7CA41E92E3CF1B8FA1354 /* Pods_Runner.framework */; }; + 2FADE93FEDA6AA758361DD61 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 14B7756C6589BB77CE45C995 /* GoogleService-Info.plist */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; @@ -40,14 +43,21 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 03C3B1F0BB0752B1FA636408 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 14B7756C6589BB77CE45C995 /* GoogleService-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Runner/GoogleService-Info.plist"; sourceTree = ""; }; + 2BD474A6B56FA1EA3653FBFA /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 4FAD09E33B5B664A8EC193E6 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 60706E6A8090A9A7C085090C /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 74C6F25649A2094800013C02 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 8DF8DF4F0A36C2ED4ABFCF2D /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -55,13 +65,24 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + A8E7CA41E92E3CF1B8FA1354 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + F03C0C3077B49238C29CE773 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 974B8BD7401DDDB77BF1A7FB /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 069003F4D939E7E87A488C82 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 1E853A3178011C9ADC9100E6 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -76,6 +97,15 @@ path = RunnerTests; sourceTree = ""; }; + 78D80D1D6B5ACA95D510622E /* Frameworks */ = { + isa = PBXGroup; + children = ( + A8E7CA41E92E3CF1B8FA1354 /* Pods_Runner.framework */, + F03C0C3077B49238C29CE773 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( @@ -94,6 +124,9 @@ 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, + BA676DF091A8BD5D2C68617F /* Pods */, + 78D80D1D6B5ACA95D510622E /* Frameworks */, + 14B7756C6589BB77CE45C995 /* GoogleService-Info.plist */, ); sourceTree = ""; }; @@ -121,6 +154,20 @@ path = Runner; sourceTree = ""; }; + BA676DF091A8BD5D2C68617F /* Pods */ = { + isa = PBXGroup; + children = ( + 60706E6A8090A9A7C085090C /* Pods-Runner.debug.xcconfig */, + 4FAD09E33B5B664A8EC193E6 /* Pods-Runner.release.xcconfig */, + 03C3B1F0BB0752B1FA636408 /* Pods-Runner.profile.xcconfig */, + 74C6F25649A2094800013C02 /* Pods-RunnerTests.debug.xcconfig */, + 2BD474A6B56FA1EA3653FBFA /* Pods-RunnerTests.release.xcconfig */, + 8DF8DF4F0A36C2ED4ABFCF2D /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -128,8 +175,10 @@ isa = PBXNativeTarget; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + D22862196FAEFCE28F2034E0 /* [CP] Check Pods Manifest.lock */, 331C807D294A63A400263BE5 /* Sources */, 331C807F294A63A400263BE5 /* Resources */, + 974B8BD7401DDDB77BF1A7FB /* Frameworks */, ); buildRules = ( ); @@ -145,12 +194,14 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + 8D17A7D05574CF36DBE7CC3C /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 9BAADD3F6CA66F4F1050A045 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -216,6 +267,7 @@ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + 2FADE93FEDA6AA758361DD61 /* GoogleService-Info.plist in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -238,6 +290,28 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; + 8D17A7D05574CF36DBE7CC3C /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -253,6 +327,45 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; + 9BAADD3F6CA66F4F1050A045 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + D22862196FAEFCE28F2034E0 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -378,6 +491,7 @@ }; 331C8088294A63A400263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 74C6F25649A2094800013C02 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -395,6 +509,7 @@ }; 331C8089294A63A400263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 2BD474A6B56FA1EA3653FBFA /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -410,6 +525,7 @@ }; 331C808A294A63A400263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 8DF8DF4F0A36C2ED4ABFCF2D /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; diff --git a/pkgs/genui_client/ios/Runner.xcworkspace/contents.xcworkspacedata b/pkgs/genui_client/ios/Runner.xcworkspace/contents.xcworkspacedata index 1d526a16e..21a3cc14c 100644 --- a/pkgs/genui_client/ios/Runner.xcworkspace/contents.xcworkspacedata +++ b/pkgs/genui_client/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/pkgs/genui_client/ios/Runner/GoogleService-Info.plist b/pkgs/genui_client/ios/Runner/GoogleService-Info.plist new file mode 100644 index 000000000..edefe9634 --- /dev/null +++ b/pkgs/genui_client/ios/Runner/GoogleService-Info.plist @@ -0,0 +1,30 @@ + + + + + API_KEY + AIzaSyCxByPgKNTJc4Gc5N0w2l-I5ffE-dWkVKg + GCM_SENDER_ID + 975757934897 + PLIST_VERSION + 1 + BUNDLE_ID + dev.flutter.genui.genuiClient + PROJECT_ID + fluttergenui + STORAGE_BUCKET + fluttergenui.firebasestorage.app + IS_ADS_ENABLED + + IS_ANALYTICS_ENABLED + + IS_APPINVITE_ENABLED + + IS_GCM_ENABLED + + IS_SIGNIN_ENABLED + + GOOGLE_APP_ID + 1:975757934897:ios:540a1a4eeeb57afb0ff010 + + \ No newline at end of file diff --git a/pkgs/genui_client/macos/Podfile.lock b/pkgs/genui_client/macos/Podfile.lock index edc6c5715..93b26a9a5 100644 --- a/pkgs/genui_client/macos/Podfile.lock +++ b/pkgs/genui_client/macos/Podfile.lock @@ -111,9 +111,9 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f Firebase: d99ac19b909cd2c548339c2241ecd0d1599ab02e - firebase_app_check: 61f24e87e25134752f63264d9bcb8198f96f241b - firebase_auth: 693f1e1ef2bb11a241d4478e63f1f47676af0538 - firebase_core: 7667f880631ae8ad10e3d6567ab7582fe0682326 + firebase_app_check: d3fa7155214c56f6c5f656c3a40ad5a025bf91b6 + firebase_auth: a48c22017e8a04bdd95b0641f5c59cbdbf04440c + firebase_core: 2af692f4818474ed52eda1ba6aeb448a6a3352af FirebaseAppCheck: 4574d7180be2a8b514f588099fc5262f032a92c7 FirebaseAppCheckInterop: 06fe5a3799278ae4667e6c432edd86b1030fa3df FirebaseAuth: a6575e5fbf46b046c58dc211a28a5fbdd8d4c83b @@ -128,4 +128,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 -COCOAPODS: 1.16.2 +COCOAPODS: 1.15.2 From b5ecdce4d606f8f3e2197bcc93241fdf8901bf92 Mon Sep 17 00:00:00 2001 From: Jacob Simionato Date: Sun, 27 Jul 2025 10:11:58 +0930 Subject: [PATCH 04/10] Add Radio and Checbox widgets --- pkgs/genui_client/lib/src/core_catalog.dart | 4 + .../lib/src/widgets/checkbox_group.dart | 96 ++++++++++++++++++ .../lib/src/widgets/radio_group.dart | 99 +++++++++++++++++++ 3 files changed, 199 insertions(+) create mode 100644 pkgs/genui_client/lib/src/widgets/checkbox_group.dart create mode 100644 pkgs/genui_client/lib/src/widgets/radio_group.dart diff --git a/pkgs/genui_client/lib/src/core_catalog.dart b/pkgs/genui_client/lib/src/core_catalog.dart index 49c22a291..23dd5b368 100644 --- a/pkgs/genui_client/lib/src/core_catalog.dart +++ b/pkgs/genui_client/lib/src/core_catalog.dart @@ -2,9 +2,13 @@ import 'catalog.dart'; import 'widgets/elevated_button.dart'; import 'widgets/column.dart'; import 'widgets/text.dart'; +import 'widgets/checkbox_group.dart'; +import 'widgets/radio_group.dart'; final coreCatalog = Catalog([ elevatedButtonCatalogItem, columnCatalogItem, text, + checkboxGroup, + radioGroup, ]); diff --git a/pkgs/genui_client/lib/src/widgets/checkbox_group.dart b/pkgs/genui_client/lib/src/widgets/checkbox_group.dart new file mode 100644 index 000000000..513826d2d --- /dev/null +++ b/pkgs/genui_client/lib/src/widgets/checkbox_group.dart @@ -0,0 +1,96 @@ +import 'package:firebase_ai/firebase_ai.dart'; +import 'package:flutter/material.dart'; + +import '../catalog_item.dart'; + +final _schema = Schema.object( + properties: { + 'values': Schema.array( + items: Schema.boolean(), + description: 'The values of the checkboxes.', + ), + 'labels': Schema.array( + items: Schema.string(), + description: 'A list of labels for the checkboxes.', + ), + }, +); + +class _CheckboxGroup extends StatefulWidget { + const _CheckboxGroup({ + required this.initialValues, + required this.labels, + required this.onChanged, + }); + + final List initialValues; + final List labels; + final void Function(List) onChanged; + + @override + State<_CheckboxGroup> createState() => _CheckboxGroupState(); +} + +class _CheckboxGroupState extends State<_CheckboxGroup> { + late List _values; + + @override + void initState() { + super.initState(); + _values = List.from(widget.initialValues); + } + + @override + void didUpdateWidget(_CheckboxGroup oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.initialValues != oldWidget.initialValues) { + _values = List.from(widget.initialValues); + } + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + for (int i = 0; i < widget.labels.length; i++) + CheckboxListTile( + title: Text(widget.labels[i]), + value: _values[i], + onChanged: (bool? newValue) { + if (newValue == null) return; + setState(() { + _values[i] = newValue; + }); + widget.onChanged(_values); + }, + controlAffinity: ListTileControlAffinity.leading, + ), + ], + ); + } +} + +Widget _builder( + dynamic data, // The actual deserialized JSON data for this layout + String id, + Widget Function(String id) buildChild, + void Function(String widgetId, String eventType, Object? value) + dispatchEvent, + BuildContext context) { + final values = (data['values'] as List).cast(); + final labels = (data['labels'] as List).cast(); + + return _CheckboxGroup( + initialValues: values, + labels: labels, + onChanged: (newValues) { + dispatchEvent(id, 'onChanged', newValues); + }, + ); +} + +final checkboxGroup = CatalogItem( + name: 'checkbox_group', + dataSchema: _schema, + widgetBuilder: _builder, +); diff --git a/pkgs/genui_client/lib/src/widgets/radio_group.dart b/pkgs/genui_client/lib/src/widgets/radio_group.dart new file mode 100644 index 000000000..5fc9e968a --- /dev/null +++ b/pkgs/genui_client/lib/src/widgets/radio_group.dart @@ -0,0 +1,99 @@ +import 'package:firebase_ai/firebase_ai.dart'; +import 'package:flutter/material.dart'; + +import '../catalog_item.dart'; + +final _schema = Schema.object( + properties: { + 'groupValue': Schema.string( + description: + 'The currently selected value for a group of radio buttons.', + ), + 'labels': Schema.array( + items: Schema.string(), + description: 'A list of labels for the radio buttons.', + ), + }, +); + +class _RadioGroup extends StatefulWidget { + const _RadioGroup({ + required this.initialGroupValue, + required this.labels, + required this.onChanged, + }); + + final String initialGroupValue; + final List labels; + final void Function(String?) onChanged; + + @override + State<_RadioGroup> createState() => _RadioGroupState(); +} + +class _RadioGroupState extends State<_RadioGroup> { + late String _groupValue; + + @override + void initState() { + super.initState(); + _groupValue = widget.initialGroupValue; + } + + @override + void didUpdateWidget(_RadioGroup oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.initialGroupValue != oldWidget.initialGroupValue) { + _groupValue = widget.initialGroupValue; + } + } + + @override + Widget build(BuildContext context) { + void changedCallback(String? newValue) { + if (newValue == null) return; + setState(() { + _groupValue = newValue; + }); + widget.onChanged(newValue); + } + + return Column( + children: widget.labels.map((label) { + return RadioListTile( + title: Text(label), + value: label, + groupValue: _groupValue, + onChanged: changedCallback, + ); + }).toList(), + ); + } +} + +Widget _builder( + dynamic data, // The actual deserialized JSON data for this layout + String id, + Widget Function(String id) buildChild, + void Function(String widgetId, String eventType, Object? value) + dispatchEvent, + BuildContext context) { + final groupValue = data['groupValue'] as String; + final labels = (data['labels'] as List).cast(); + + return _RadioGroup( + initialGroupValue: groupValue, + labels: labels, + onChanged: (newValue) { + if (newValue != null) { + dispatchEvent(id, 'onChanged', newValue); + } + }, + ); +} + +final radioGroup = CatalogItem( + name: 'radio_group', + dataSchema: _schema, + widgetBuilder: _builder, +); From 5740e1ffbaa4000d246546573b54fc9921231622 Mon Sep 17 00:00:00 2001 From: Jacob Simionato Date: Sun, 27 Jul 2025 10:23:34 +0930 Subject: [PATCH 05/10] Add text field support --- pkgs/genui_client/lib/src/core_catalog.dart | 2 + .../lib/src/widgets/text_field.dart | 102 ++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 pkgs/genui_client/lib/src/widgets/text_field.dart diff --git a/pkgs/genui_client/lib/src/core_catalog.dart b/pkgs/genui_client/lib/src/core_catalog.dart index 23dd5b368..da2a942cf 100644 --- a/pkgs/genui_client/lib/src/core_catalog.dart +++ b/pkgs/genui_client/lib/src/core_catalog.dart @@ -4,6 +4,7 @@ import 'widgets/column.dart'; import 'widgets/text.dart'; import 'widgets/checkbox_group.dart'; import 'widgets/radio_group.dart'; +import 'widgets/text_field.dart'; final coreCatalog = Catalog([ elevatedButtonCatalogItem, @@ -11,4 +12,5 @@ final coreCatalog = Catalog([ text, checkboxGroup, radioGroup, + textField, ]); diff --git a/pkgs/genui_client/lib/src/widgets/text_field.dart b/pkgs/genui_client/lib/src/widgets/text_field.dart new file mode 100644 index 000000000..c20b6ab76 --- /dev/null +++ b/pkgs/genui_client/lib/src/widgets/text_field.dart @@ -0,0 +1,102 @@ +import 'package:firebase_ai/firebase_ai.dart'; +import 'package:flutter/material.dart'; + +import '../catalog_item.dart'; + +final _schema = Schema.object( + properties: { + 'value': Schema.string( + description: 'The initial value of the text field.', + ), + 'hintText': Schema.string( + description: 'Hint text for the text field.', + ), + 'obscureText': Schema.boolean( + description: 'Whether the text should be obscured.', + ), + }, +); + +class _TextField extends StatefulWidget { + const _TextField({ + required this.initialValue, + this.hintText, + this.obscureText = false, + required this.onChanged, + required this.onSubmitted, + }); + + final String initialValue; + final String? hintText; + final bool obscureText; + final void Function(String) onChanged; + final void Function(String) onSubmitted; + + @override + State<_TextField> createState() => _TextFieldState(); +} + +class _TextFieldState extends State<_TextField> { + late final TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.initialValue); + } + + @override + void didUpdateWidget(_TextField oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.initialValue != oldWidget.initialValue) { + _controller.text = widget.initialValue; + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return TextField( + controller: _controller, + decoration: InputDecoration(hintText: widget.hintText), + obscureText: widget.obscureText, + onChanged: widget.onChanged, + onSubmitted: widget.onSubmitted, + ); + } +} + +Widget _builder( + dynamic data, // The actual deserialized JSON data for this layout + String id, + Widget Function(String id) buildChild, + void Function(String widgetId, String eventType, Object? value) + dispatchEvent, + BuildContext context) { + final value = data['value'] as String? ?? ''; + final hintText = data['hintText'] as String?; + final obscureText = data['obscureText'] as bool? ?? false; + + return _TextField( + initialValue: value, + hintText: hintText, + obscureText: obscureText, + onChanged: (newValue) { + dispatchEvent(id, 'onChanged', newValue); + }, + onSubmitted: (newValue) { + dispatchEvent(id, 'onSubmitted', newValue); + }, + ); +} + +final textField = CatalogItem( + name: 'text_field', + dataSchema: _schema, + widgetBuilder: _builder, +); From adb838f2bc69f0e9b6280abf480f89b6d397eea7 Mon Sep 17 00:00:00 2001 From: Jacob Simionato Date: Sun, 27 Jul 2025 10:24:01 +0930 Subject: [PATCH 06/10] Add Gemini.md file to describe CatalogItem --- pkgs/genui_client/GEMINI.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 pkgs/genui_client/GEMINI.md diff --git a/pkgs/genui_client/GEMINI.md b/pkgs/genui_client/GEMINI.md new file mode 100644 index 000000000..2ef2b77e4 --- /dev/null +++ b/pkgs/genui_client/GEMINI.md @@ -0,0 +1,31 @@ +# CatalogItem + +A CatalogItem is an object which represents a widget that can be instantiated by an LLM. It centralizes the builder function for the widget along with the data schema and name. + +The codebase is in the process of migrating from an older structure where building logic for a widget was defined in lib/src/dynamic_ui.dart and the schema was defined in lib/src/ui_schema.dart and the deserialization logic was in lib/src/ui_models.dart. These lines may be commented out, but you can still read them. In the new structure, we instead want to have a file for each CatalogItem in lib/src/widgets/ that contains the builder logic, schema and deserialization logic together. + +Also, in the old structure, the schema represented each widget with a single object type that had many optional properties - see lib/src/ui_schema.dart. In the new approach, we will have a separate object for each widget type, with only relevant properties, mostly required. See lib/src/schema_generator.dart. + +## Structure of a CatalogItem + +- CatalogItems should *not* depend on ui_models.dart. Instead, they should include a builder function that inlines all the map access logic. +- Only the variable that defines the CatalogItem should be public. Everything else should be private. +- The Schema must be defined as a Schema object. Look at lib/src/widgets/elevated_button.dart as a guide. Remember to use `Schema.object` and `Schema.enumString` etc. Specify `optionalProperties` rather than `required`. +- The Schema should *not* have a "props" member or an "id" member, as those will be injected at a higher level. The schema should just be an object that includes all the properties that are specific to this widget, e.g. content to display. +- The only imports should be 'package:firebase_ai/firebase_ai.dart', 'package:flutter/material.dart' and '../catalog_item.dart'. Any other utilities etc that are needed should be inlined into the file. +- The name of the CatalogItem should be in lower camel case. +- Widgets that require controllers or state in order to instantly reflect user inputs (e.g. Radio buttons, checkboxes, text fields) should be implemented as StatefulWidgets which maintain internal UI state. Look at libs/src/widgets/radio_group.dart as an example. There should be a private StatefulWidget class definition in the file. + +## How to migrate an existing supported widget to a CatalogItem + +To migrate an existing supported widget using the older structure to a new CatalogItem: + +1. Look at the existing definition of the widget in lib/src/ui_schema.dart and lib/src/dynamic_ui.dart. + +2. Understand the structure of a CatalogItem by looking at lib/src/catalog_item.dart for the type, and lib/src/widgets/elevated_button.dart as an example to follow. + +3. Create a new file under lib/src/widgets which contains a declaration of a global variable with a CatalogItem. The item should use a schema that includes all the relevant fields from ui_schema.dart, and a builder function which correctly accesses the data according to the schema. + +4. Add the new CatalogItem to lib/src/core_catalog.dart + +Note: don't delete any of the code that the new CatalogItem is based on. We will delete it all later once all the widgets are migrated. From ef2a0305beb1f39adcc68398915c8c456db497c4 Mon Sep 17 00:00:00 2001 From: Jacob Simionato Date: Sun, 27 Jul 2025 10:31:11 +0930 Subject: [PATCH 07/10] Remove commented out code --- pkgs/genui_client/lib/src/dynamic_ui.dart | 269 ------------- pkgs/genui_client/lib/src/ui_schema.dart | 198 ---------- pkgs/genui_client/test/dynamic_ui_test.dart | 415 -------------------- 3 files changed, 882 deletions(-) delete mode 100644 pkgs/genui_client/lib/src/ui_schema.dart diff --git a/pkgs/genui_client/lib/src/dynamic_ui.dart b/pkgs/genui_client/lib/src/dynamic_ui.dart index 5ba15e3f6..1d2f9d004 100644 --- a/pkgs/genui_client/lib/src/dynamic_ui.dart +++ b/pkgs/genui_client/lib/src/dynamic_ui.dart @@ -32,76 +32,6 @@ class DynamicUi extends StatefulWidget { } class _DynamicUiState extends State { - /// Stores the current props for every widget, keyed by widget ID. - /// This allows for efficient state updates. - // late final Map> _widgetStates; - // final Map _textControllers = {}; - // late final UiDefinition _uiDefinition; - - /// When the widget is replaced with a new one (e.g., due to a key change), - /// we must re-initialize its state. - @override - void didUpdateWidget(covariant DynamicUi oldWidget) { - super.didUpdateWidget(oldWidget); - // if (widget.definition != oldWidget.definition) { - // _cleanupState(); - // _initializeState(); - // } - } - - // void _initializeState() { - // final definition = Map.from(widget.definition); - // final widgets = definition['widgets']; - - // // The schema defines `widgets` as a list of widget definitions, but this - // // class expects `widgets` to be a map from widget ID to widget definition, - // // so we convert the list to a map here. - // if (widgets is List) { - // definition['widgets'] = { - // for (final widgetDef in widgets) - // if (widgetDef is Map && widgetDef['id'] is String) - // widgetDef['id'] as String: widgetDef, - // }; - // } - - // _uiDefinition = UiDefinition.fromMap(definition); - // _widgetStates = {}; - // _populateInitialStates(); - // } - - // void _cleanupState() { - // for (var controller in _textControllers.values) { - // controller.dispose(); - // } - // _textControllers.clear(); - // _widgetStates.clear(); - // } - - @override - void dispose() { - //_cleanupState(); - super.dispose(); - } - - /// Traverses the initial UI definition to populate the - /// [_widgetStates] map and create TextEditingControllers. - // void _populateInitialStates() { - // for (final widgetDefEntry in _uiDefinition.widgets.entries) { - // final widgetDef = WidgetDefinition.fromMap(widgetDefEntry.value); - // final id = widgetDef.id; - // // Make a mutable copy - // final props = Map.from(widgetDef.props); - - // _widgetStates[id] = props; - - // if (widgetDef.type == 'TextField') { - // final textField = UiTextField.fromMap({'props': props}); - // final controller = TextEditingController(text: textField.value); - // _textControllers[id] = controller; - // } - // } - // } - /// Dispatches an event by calling the public [DynamicUi.onEvent] callback. void _dispatchEvent(String widgetId, String eventType, Object? value) { final event = UiEvent( @@ -132,204 +62,5 @@ class _DynamicUiState extends State { _buildWidget, _dispatchEvent, context); - - // final widgetDefMap = _uiDefinition.widgets[widgetId]; - // if (widgetDefMap == null) { - // return Text('Unknown widget ID: $widgetId'); - // } - // final widgetDef = WidgetDefinition.fromMap(widgetDefMap); - // final id = widgetDef.id; - // final type = widgetDef.type; - - // // Always get the latest props from our state map. - // final props = _widgetStates[id] ?? widgetDef.props; - - // switch (type) { - // case 'Text': - // final text = UiText.fromMap({'props': props}); - // return Text( - // text.data, - // style: TextStyle( - // fontSize: text.fontSize, - // fontWeight: - // text.fontWeight == 'bold' ? FontWeight.bold : FontWeight.normal, - // ), - // ); - // case 'TextField': - // final textField = UiTextField.fromMap({'props': props}); - // final controller = _textControllers[id]!; - // return ConstrainedBox( - // constraints: const BoxConstraints( - // maxWidth: 500, - // ), - // child: TextField( - // controller: controller, - // decoration: InputDecoration(hintText: textField.hintText), - // obscureText: textField.obscureText, - // onChanged: (value) => _dispatchEvent(id, 'onChanged', value), - // onSubmitted: (value) => _dispatchEvent(id, 'onSubmitted', value), - // ), - // ); - // case 'Checkbox': - // final checkbox = UiCheckbox.fromMap({'props': props}); - // if (checkbox.label != null) { - // return CheckboxListTile( - // title: Text(checkbox.label!), - // value: checkbox.value, - // onChanged: (value) => _dispatchEvent(id, 'onChanged', value), - // controlAffinity: ListTileControlAffinity.leading, - // ); - // } - // return Checkbox( - // value: checkbox.value, - // onChanged: (value) => _dispatchEvent(id, 'onChanged', value), - // ); - // case 'Radio': - // final radio = UiRadio.fromMap({'props': props}); - // void changedCallback(Object? newValue) { - // if (newValue == null) return; - // _dispatchEvent(id, 'onChanged', newValue); - // } - - // if (radio.label != null) { - // return RadioListTile( - // title: Text(radio.label!), - // value: radio.value, - // // ignore: deprecated_member_use - // groupValue: radio.groupValue, - // // ignore: deprecated_member_use - // onChanged: changedCallback, - // ); - // } - // return Radio( - // value: radio.value, - // // ignore: deprecated_member_use - // groupValue: radio.groupValue, - // // ignore: deprecated_member_use - // onChanged: changedCallback, - // ); - // case 'Slider': - // final slider = UiSlider.fromMap({'props': props}); - // return Slider( - // value: slider.value, - // min: slider.min, - // max: slider.max, - // divisions: slider.divisions, - // label: slider.value.round().toString(), - // onChanged: (value) => _dispatchEvent(id, 'onChanged', value), - // ); - // case 'Align': - // final align = UiAlign.fromMap({'props': props}); - // return Align( - // alignment: _parseAlignment(align.alignment), - // child: align.child != null ? _buildWidget(align.child!) : null, - // ); - // case 'Column': - // final column = UiContainer.fromMap({'props': props}); - // return Column( - // mainAxisAlignment: _parseMainAxisAlignment(column.mainAxisAlignment), - // crossAxisAlignment: - // _parseCrossAxisAlignment(column.crossAxisAlignment), - // children: (column.children ?? []).map(_buildWidget).toList(), - // ); - // case 'Row': - // final row = UiContainer.fromMap({'props': props}); - // return Row( - // mainAxisAlignment: _parseMainAxisAlignment(row.mainAxisAlignment), - // crossAxisAlignment: _parseCrossAxisAlignment(row.crossAxisAlignment), - // children: (row.children ?? []).map(_buildWidget).toList(), - // ); - // case 'ElevatedButton': - // final button = UiElevatedButton.fromMap({'props': props}); - // return ElevatedButton( - // onPressed: () => _dispatchEvent(id, 'onTap', null), - // child: button.child != null ? _buildWidget(button.child!) : null, - // ); - // case 'Padding': - // final padding = UiPadding.fromMap({'props': props}); - // return Padding( - // padding: _parseEdgeInsets(padding.padding), - // child: padding.child != null ? _buildWidget(padding.child!) : null, - // ); - // default: - // return Text('Unknown widget type: $type'); - // } } - - // --- Parsing Helper Functions --- - - /// Parses a [UiEdgeInsets] object into a Flutter [EdgeInsets] object. - // EdgeInsets _parseEdgeInsets(UiEdgeInsets edgeInsets) { - // return EdgeInsets.fromLTRB( - // edgeInsets.left, - // edgeInsets.top, - // edgeInsets.right, - // edgeInsets.bottom, - // ); - // } - - // /// Parses a string representation of an alignment into a Flutter - // /// [Alignment] object. - // Alignment _parseAlignment(String? alignment) { - // switch (alignment) { - // case 'topLeft': - // return Alignment.topLeft; - // case 'topCenter': - // return Alignment.topCenter; - // case 'topRight': - // return Alignment.topRight; - // case 'centerLeft': - // return Alignment.centerLeft; - // case 'center': - // return Alignment.center; - // case 'centerRight': - // return Alignment.centerRight; - // case 'bottomLeft': - // return Alignment.bottomLeft; - // case 'bottomCenter': - // return Alignment.bottomCenter; - // case 'bottomRight': - // return Alignment.bottomRight; - // default: - // return Alignment.center; - // } - // } - - // /// Parses a string representation of a main axis alignment into a Flutter - // /// [MainAxisAlignment] object. - // MainAxisAlignment _parseMainAxisAlignment(String? alignment) { - // switch (alignment) { - // case 'start': - // return MainAxisAlignment.start; - // case 'center': - // return MainAxisAlignment.center; - // case 'end': - // return MainAxisAlignment.end; - // case 'spaceBetween': - // return MainAxisAlignment.spaceBetween; - // case 'spaceAround': - // return MainAxisAlignment.spaceAround; - // case 'spaceEvenly': - // return MainAxisAlignment.spaceEvenly; - // default: - // return MainAxisAlignment.start; - // } - // } - - /// Parses a string representation of a cross axis alignment into a Flutter - /// [CrossAxisAlignment] object. - // CrossAxisAlignment _parseCrossAxisAlignment(String? alignment) { - // switch (alignment) { - // case 'start': - // return CrossAxisAlignment.start; - // case 'center': - // return CrossAxisAlignment.center; - // case 'end': - // return CrossAxisAlignment.end; - // case 'stretch': - // return CrossAxisAlignment.stretch; - // default: - // return CrossAxisAlignment.center; - // } - // } } diff --git a/pkgs/genui_client/lib/src/ui_schema.dart b/pkgs/genui_client/lib/src/ui_schema.dart deleted file mode 100644 index bf0285da0..000000000 --- a/pkgs/genui_client/lib/src/ui_schema.dart +++ /dev/null @@ -1,198 +0,0 @@ -import 'package:firebase_ai/firebase_ai.dart'; - -/// A schema for defining a simple UI tree to be rendered by Flutter. -/// -/// This schema is a Dart conversion of a more complex JSON schema. -/// Due to limitations in the Dart `Schema` builder API (specifically the lack -/// of support for discriminated unions or `anyOf`), this conversion makes a -/// practical compromise. -/// -/// It strictly enforces the structure of the `root` object, requiring `id` -/// and `type` for every widget in the `widgets` list. The `props` field -/// within each widget is defined as a `Schema.object` with all possible -/// properties for all widget types. The application logic should validate the -/// contents of `props` based on the widget's `type`. -/// -/// This approach ensures that the fundamental structure of the UI definition -/// is always valid according to the schema. -final flutterUiDefinition = Schema.object( - properties: { - 'responseText': Schema.string( - description: 'The text response to the user query. This should be used ' - 'when the query is fully satisfied and no more information is ' - 'needed.', - ), - 'actions': Schema.array( - description: 'A list of actions to be performed on the UI surfaces.', - items: Schema.object( - properties: { - 'action': Schema.enumString( - description: 'The action to perform on the UI surface.', - enumValues: ['add', 'update', 'delete'], - ), - 'surfaceId': Schema.string( - description: - 'The ID of the surface to perform the action on. For the ' - '`add` action, this will be a new surface ID. For `update` and ' - '`delete`, this will be an existing surface ID.', - ), - 'definition': Schema.object( - properties: { - 'root': Schema.string( - description: 'The ID of the root widget.', - ), - 'widgets': Schema.array( - items: Schema.object( - properties: { - 'id': Schema.string( - description: - 'A unique identifier for the widget instance.', - ), - 'type': Schema.enumString( - description: 'The type of the widget.', - enumValues: [ - 'Align', - 'Column', - 'Row', - 'Text', - 'TextField', - 'Checkbox', - 'Radio', - 'Slider', - 'ElevatedButton', - 'Padding', - ], - ), - 'props': Schema.object( - properties: { - 'alignment': Schema.string( - description: 'The alignment of the child. See ' - 'Flutter\'s Alignment for possible values. ' - 'Required for Align widgets.', - ), - 'child': Schema.string( - description: - 'The ID of a child widget. Required for Align, ' - 'ElevatedButton, and Padding widgets.', - ), - 'children': Schema.array( - items: Schema.string(), - description: 'A list of widget IDs for the ' - 'children. Required for Column and Row widgets.', - ), - 'crossAxisAlignment': Schema.string( - description: - 'How children are aligned on the cross axis. ' - 'See Flutter\'s CrossAxisAlignment for values. ' - 'Required for Column and Row widgets.', - ), - 'data': Schema.string( - description: - 'The text content. Required for Text widgets.', - ), - 'divisions': Schema.integer( - description: 'The number of discrete intervals on ' - 'the slider. Required for Slider widgets.', - ), - 'fontSize': Schema.number( - description: 'The font size for the text. Required ' - 'for Text widgets.', - ), - 'fontWeight': Schema.string( - description: 'The font weight (e.g., "bold"). ' - 'Required for Text widgets.', - ), - 'groupValue': Schema.string( - description: 'The currently selected value for a ' - 'group of radio buttons. The type of this ' - 'property should match the type of the "value" ' - 'property. Required for Radio widgets.', - ), - 'hintText': Schema.string( - description: 'Hint text for the text field. ' - 'Required for TextField widgets.', - ), - 'label': Schema.string( - description: 'A label displayed next to the widget. ' - 'Required for Checkbox and Radio widgets.', - ), - 'mainAxisAlignment': Schema.string( - description: - 'How children are aligned on the main axis. ' - 'See Flutter\'s MainAxisAlignment for values. ' - 'Required for Column and Row widgets.', - ), - 'max': Schema.number( - description: 'The maximum value for the slider. ' - 'Required for Slider widgets.', - ), - 'min': Schema.number( - description: 'The minimum value for the slider. ' - 'Required for Slider widgets.', - ), - 'obscureText': Schema.boolean( - description: - 'Whether the text should be obscured (e.g., for ' - 'passwords). Required for TextField widgets.', - ), - 'padding': Schema.object( - properties: { - 'left': Schema.number(), - 'top': Schema.number(), - 'right': Schema.number(), - 'bottom': Schema.number(), - }, - description: 'The padding around the child. ' - 'Required for Padding widgets.', - ), - 'value': Schema.string( - description: - 'The value of the widget. Type varies: String ' - 'for TextField, boolean for Checkbox, double for ' - 'Slider, and any type for Radio. Required for ' - 'TextField, Checkbox, Radio, and Slider widgets.', - ), - }, - optionalProperties: [ - 'alignment', - 'child', - 'children', - 'crossAxisAlignment', - 'data', - 'divisions', - 'fontSize', - 'fontWeight', - 'groupValue', - 'hintText', - 'label', - 'mainAxisAlignment', - 'max', - 'min', - 'obscureText', - 'padding', - 'value', - ], - description: - 'A map of properties specific to this widget type. ' - 'Its structure depends on the value of the "type" ' - 'field.', - ), - }, - optionalProperties: ['props'], - ), - description: 'A list of widget definitions.', - ), - }, - description: - 'A schema for defining a simple UI tree to be rendered by ' - 'Flutter.', - ), - }, - optionalProperties: ['surfaceId', 'definition'], - ), - ), - }, - description: 'A schema for defining a simple UI tree to be rendered by ' - 'Flutter.', - optionalProperties: ['actions', 'responseText'], -); diff --git a/pkgs/genui_client/test/dynamic_ui_test.dart b/pkgs/genui_client/test/dynamic_ui_test.dart index 122a8b1fb..8b1378917 100644 --- a/pkgs/genui_client/test/dynamic_ui_test.dart +++ b/pkgs/genui_client/test/dynamic_ui_test.dart @@ -1,416 +1 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:genui_client/src/dynamic_ui.dart'; -void main() { - group('DynamicUi', () { - testWidgets('builds a simple Text widget', (WidgetTester tester) async { - final definition = { - 'root': 'text1', - 'widgets': [ - { - 'id': 'text1', - 'type': 'Text', - 'props': { - 'data': 'Hello, World!', - 'fontSize': 18.0, - 'fontWeight': 'bold' - }, - } - ], - }; - - await tester.pumpWidget(MaterialApp( - home: Scaffold( - body: DynamicUi( - surfaceId: 'test-surface', - definition: definition, - onEvent: (_) {}, - ), - ), - )); - - expect(find.text('Hello, World!'), findsOneWidget); - final text = tester.widget(find.text('Hello, World!')); - expect(text.style?.fontSize, 18.0); - expect(text.style?.fontWeight, FontWeight.bold); - }); - - testWidgets('builds a Column with children', (WidgetTester tester) async { - final definition = { - 'root': 'col1', - 'widgets': [ - { - 'id': 'col1', - 'type': 'Column', - 'props': { - 'children': ['text1', 'text2'], - 'mainAxisAlignment': 'center', - 'crossAxisAlignment': 'end', - }, - }, - { - 'id': 'text1', - 'type': 'Text', - 'props': {'data': 'First'}, - }, - { - 'id': 'text2', - 'type': 'Text', - 'props': {'data': 'Second'}, - }, - ], - }; - - await tester.pumpWidget(MaterialApp( - home: Scaffold( - body: DynamicUi( - surfaceId: 'test-surface', - definition: definition, - onEvent: (_) {}, - ), - ), - )); - - expect(find.text('First'), findsOneWidget); - expect(find.text('Second'), findsOneWidget); - final column = tester.widget(find.byType(Column)); - expect(column.children.length, 2); - expect(column.mainAxisAlignment, MainAxisAlignment.center); - expect(column.crossAxisAlignment, CrossAxisAlignment.end); - }); - - testWidgets('builds a Row with children', (WidgetTester tester) async { - final definition = { - 'root': 'row1', - 'widgets': [ - { - 'id': 'row1', - 'type': 'Row', - 'props': { - 'children': ['text1', 'text2'], - 'mainAxisAlignment': 'spaceBetween', - 'crossAxisAlignment': 'start', - }, - }, - { - 'id': 'text1', - 'type': 'Text', - 'props': {'data': 'First'}, - }, - { - 'id': 'text2', - 'type': 'Text', - 'props': {'data': 'Second'}, - }, - ], - }; - - await tester.pumpWidget(MaterialApp( - home: Scaffold( - body: DynamicUi( - surfaceId: 'test-surface', - definition: definition, - onEvent: (_) {}, - ), - ), - )); - - expect(find.text('First'), findsOneWidget); - expect(find.text('Second'), findsOneWidget); - final row = tester.widget(find.byType(Row)); - expect(row.children.length, 2); - expect(row.mainAxisAlignment, MainAxisAlignment.spaceBetween); - expect(row.crossAxisAlignment, CrossAxisAlignment.start); - }); - - testWidgets('sends an event on button tap', (WidgetTester tester) async { - Map? capturedEvent; - final definition = { - 'root': 'button1', - 'widgets': [ - { - 'id': 'button1', - 'type': 'ElevatedButton', - 'props': { - 'child': 'button_text', - }, - }, - { - 'id': 'button_text', - 'type': 'Text', - 'props': {'data': 'Tap Me'}, - } - ], - }; - - await tester.pumpWidget(MaterialApp( - home: Scaffold( - body: DynamicUi( - surfaceId: 'test-surface', - definition: definition, - onEvent: (event) { - capturedEvent = event; - }, - ), - ), - )); - - await tester.tap(find.byType(ElevatedButton)); - await tester.pump(); - - expect(capturedEvent, isNotNull); - expect(capturedEvent!['widgetId'], 'button1'); - expect(capturedEvent!['eventType'], 'onTap'); - }); - - testWidgets('handles TextField input', (WidgetTester tester) async { - Map? capturedEvent; - final definition = { - 'root': 'field1', - 'widgets': [ - { - 'id': 'field1', - 'type': 'TextField', - 'props': { - 'value': 'Initial', - 'hintText': 'Enter text', - 'obscureText': true - }, - } - ], - }; - - await tester.pumpWidget(MaterialApp( - home: Scaffold( - body: DynamicUi( - surfaceId: 'test-surface', - definition: definition, - onEvent: (event) { - capturedEvent = event; - }, - ), - ), - )); - - final textFieldFinder = find.byType(TextField); - final textField = tester.widget(textFieldFinder); - expect(textField.controller!.text, 'Initial'); - expect(textField.decoration!.hintText, 'Enter text'); - expect(textField.obscureText, isTrue); - - await tester.enterText(textFieldFinder, 'New Value'); - await tester.pump(); - - expect(capturedEvent, isNotNull); - expect(capturedEvent!['widgetId'], 'field1'); - expect(capturedEvent!['eventType'], 'onChanged'); - expect(capturedEvent!['value'], 'New Value'); - }); - - testWidgets('builds a Checkbox widget', (WidgetTester tester) async { - Map? capturedEvent; - final definition = { - 'root': 'check1', - 'widgets': [ - { - 'id': 'check1', - 'type': 'Checkbox', - 'props': {'value': true, 'label': 'A checkbox'}, - } - ], - }; - - await tester.pumpWidget(MaterialApp( - home: Scaffold( - body: DynamicUi( - surfaceId: 'test-surface', - definition: definition, - onEvent: (event) { - capturedEvent = event; - }, - ), - ), - )); - - final checkboxFinder = find.byType(CheckboxListTile); - expect(checkboxFinder, findsOneWidget); - final checkbox = tester.widget(checkboxFinder); - expect(checkbox.value, isTrue); - expect(find.text('A checkbox'), findsOneWidget); - - await tester.tap(checkboxFinder); - await tester.pump(); - - expect(capturedEvent, isNotNull); - expect(capturedEvent!['widgetId'], 'check1'); - expect(capturedEvent!['eventType'], 'onChanged'); - expect(capturedEvent!['value'], isFalse); - }); - - testWidgets('builds a Radio widget', (WidgetTester tester) async { - Map? capturedEvent; - final definition = { - 'root': 'radio1', - 'widgets': [ - { - 'id': 'radio1', - 'type': 'Radio', - 'props': {'value': 'A', 'groupValue': 'B', 'label': 'Option A'}, - } - ], - }; - - await tester.pumpWidget(MaterialApp( - home: Scaffold( - body: DynamicUi( - surfaceId: 'test-surface', - definition: definition, - onEvent: (event) { - capturedEvent = event; - }, - ), - ), - )); - - final radioFinder = find.byType(RadioListTile); - expect(radioFinder, findsOneWidget); - final radio = tester.widget>(radioFinder); - expect(radio.value, 'A'); - // ignore: deprecated_member_use - expect(radio.groupValue, 'B'); - expect(find.text('Option A'), findsOneWidget); - - await tester.tap(radioFinder); - await tester.pump(); - - expect(capturedEvent, isNotNull); - expect(capturedEvent!['widgetId'], 'radio1'); - expect(capturedEvent!['eventType'], 'onChanged'); - expect(capturedEvent!['value'], 'A'); - }); - - testWidgets('builds a Slider widget', (WidgetTester tester) async { - Map? capturedEvent; - final definition = { - 'root': 'slider1', - 'widgets': [ - { - 'id': 'slider1', - 'type': 'Slider', - 'props': {'value': 50.0, 'min': 0.0, 'max': 100.0, 'divisions': 10}, - } - ], - }; - - await tester.pumpWidget(MaterialApp( - home: Scaffold( - body: DynamicUi( - surfaceId: 'test-surface', - definition: definition, - onEvent: (event) { - capturedEvent = event; - }, - ), - ), - )); - - final sliderFinder = find.byType(Slider); - expect(sliderFinder, findsOneWidget); - final slider = tester.widget(sliderFinder); - expect(slider.value, 50.0); - expect(slider.min, 0.0); - expect(slider.max, 100.0); - expect(slider.divisions, 10); - - // This is how you drag a slider. - final center = tester.getCenter(sliderFinder); - final target = center.translate(100, 0); - final gesture = await tester.startGesture(center); - await gesture.moveTo(target); - await gesture.up(); - await tester.pump(); - - expect(capturedEvent, isNotNull); - expect(capturedEvent!['widgetId'], 'slider1'); - expect(capturedEvent!['eventType'], 'onChanged'); - // The exact value depends on the gesture, so we just check the type. - expect(capturedEvent!['value'], isA()); - }); - - testWidgets('builds an Align widget', (WidgetTester tester) async { - final definition = { - 'root': 'align1', - 'widgets': [ - { - 'id': 'align1', - 'type': 'Align', - 'props': { - 'alignment': 'topRight', - 'child': 'text1', - }, - }, - { - 'id': 'text1', - 'type': 'Text', - 'props': {'data': 'Aligned'}, - } - ], - }; - - await tester.pumpWidget(MaterialApp( - home: Scaffold( - body: DynamicUi( - surfaceId: 'test-surface', - definition: definition, - onEvent: (_) {}, - ), - ), - )); - - final align = tester.widget(find.byType(Align)); - expect(align.alignment, Alignment.topRight); - expect(find.text('Aligned'), findsOneWidget); - }); - - testWidgets('builds a Padding widget', (WidgetTester tester) async { - final definition = { - 'root': 'padding1', - 'widgets': [ - { - 'id': 'padding1', - 'type': 'Padding', - 'props': { - 'padding': { - 'left': 10.0, - 'top': 20.0, - 'right': 30.0, - 'bottom': 40.0 - }, - 'child': 'text1', - }, - }, - { - 'id': 'text1', - 'type': 'Text', - 'props': {'data': 'Padded'}, - } - ], - }; - - await tester.pumpWidget(MaterialApp( - home: Scaffold( - body: DynamicUi( - surfaceId: 'test-surface', - definition: definition, - onEvent: (_) {}, - ), - ), - )); - - final padding = tester.widget(find.byType(Padding)); - expect(padding.padding, const EdgeInsets.fromLTRB(10, 20, 30, 40)); - expect(find.text('Padded'), findsOneWidget); - }); - }); -} From 3e7414ca43fa1e2c8cd0839e09cbd2ca5f524e04 Mon Sep 17 00:00:00 2001 From: Jacob Simionato Date: Sun, 27 Jul 2025 10:38:05 +0930 Subject: [PATCH 08/10] Update gitignore to ignore firepit-log.txt --- pkgs/genui_client/.gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/pkgs/genui_client/.gitignore b/pkgs/genui_client/.gitignore index e1b599aee..c8c4e1cc0 100644 --- a/pkgs/genui_client/.gitignore +++ b/pkgs/genui_client/.gitignore @@ -11,6 +11,7 @@ .svn/ .swiftpm/ migrate_working_dir/ +firepit-log.txt # IntelliJ related *.iml From 6b940d35c5ccc2fad05b5a0db6ab31d349a3663c Mon Sep 17 00:00:00 2001 From: Jacob Simionato Date: Sun, 27 Jul 2025 10:38:28 +0930 Subject: [PATCH 09/10] Remove unused code and update docs --- pkgs/genui_client/lib/src/catalog_item.dart | 15 +- pkgs/genui_client/lib/src/ui_models.dart | 156 -------------------- 2 files changed, 9 insertions(+), 162 deletions(-) diff --git a/pkgs/genui_client/lib/src/catalog_item.dart b/pkgs/genui_client/lib/src/catalog_item.dart index 29853af6b..fa0d7bf50 100644 --- a/pkgs/genui_client/lib/src/catalog_item.dart +++ b/pkgs/genui_client/lib/src/catalog_item.dart @@ -2,17 +2,20 @@ import 'package:flutter/material.dart'; import 'package:firebase_ai/firebase_ai.dart'; typedef CatalogWidgetBuilder = Widget Function( - dynamic data, // The actual deserialized JSON data for this layout - String id, - Widget Function(String id) buildChild, - void Function(String widgetId, String eventType, Object? value) dispatchEvent, - BuildContext context, + dynamic + data, // The actual deserialized JSON data for this widget. The format of this data will exactly match dataSchema below. + String id, // The ID of this widget. + Widget Function(String id) + buildChild, // A function used to build a child based on the given ID. + void Function(String widgetId, String eventType, Object? value) + dispatchEvent, // A function used to dispatch an event. + BuildContext context, // The build context. ); /// Defines a UI layout type, its schema, and how to build its widget. class CatalogItem { final String name; // The key used in JSON, e.g., 'text_chat_message' - final Schema dataSchema; // The schema definition for this layout's data + final Schema dataSchema; // The schema definition for this widget's data. final CatalogWidgetBuilder widgetBuilder; CatalogItem({ diff --git a/pkgs/genui_client/lib/src/ui_models.dart b/pkgs/genui_client/lib/src/ui_models.dart index ee6df58f4..5f64f6cb0 100644 --- a/pkgs/genui_client/lib/src/ui_models.dart +++ b/pkgs/genui_client/lib/src/ui_models.dart @@ -69,8 +69,6 @@ extension type UiStateUpdate.fromMap(Map _json) { Map get props => _json['props'] as Map; } -// --- Base Widget Definition Models --- - /// A data object that represents the entire UI definition. /// /// This is the root object that defines a complete UI to be rendered. @@ -83,8 +81,6 @@ extension type UiDefinition.fromMap(Map _json) { /// A map of all widget definitions in the UI, keyed by their ID. Map get widgets { - print('JSON for widgets is $_json'); - final widgetById = {}; for (final widget in (_json['widgets'] as List)) { @@ -96,155 +92,3 @@ extension type UiDefinition.fromMap(Map _json) { return widgetById; } } - -/// A data object that represents a single widget definition. -/// -/// This contains the basic information needed to render any widget, including -/// its ID, type, and properties. -extension type WidgetDefinition.fromMap(Map _json) { - /// The unique ID of the widget. - String get id => _json['id'] as String; - - /// The type of the widget (e.g., 'Text', 'Column'). - String get type => _json['type'] as String; - - /// The map of properties for this widget, which are specific to the widget's - /// [type]. - Map get props => - _json['props'] as Map? ?? {}; -} - -// --- Specific Widget Property Accessors --- - -/// A data object for accessing the properties of a `Text` widget. -extension type UiText.fromMap(Map _json) { - Map get _props => _json['props'] as Map; - - /// The string content of the text. - String get data => _props['data'] as String? ?? ''; - - /// The font size for the text. - double get fontSize => (_props['fontSize'] as num?)?.toDouble() ?? 14.0; - - /// The font weight for the text (e.g., 'bold', 'normal'). - String get fontWeight => _props['fontWeight'] as String? ?? 'normal'; -} - -/// A data object for accessing the properties of a `TextField` widget. -extension type UiTextField.fromMap(Map _json) { - Map get _props => _json['props'] as Map; - - /// The current text value of the text field. - String get value => _props['value'] as String? ?? ''; - - /// The hint text to display when the text field is empty. - String? get hintText => _props['hintText'] as String?; - - /// Whether to obscure the text being entered (e.g., for a password). - bool get obscureText => _props['obscureText'] as bool? ?? false; -} - -/// A data object for accessing the properties of a `Checkbox` widget. -extension type UiCheckbox.fromMap(Map _json) { - Map get _props => _json['props'] as Map; - - /// The current state of the checkbox (true if checked, false if unchecked). - bool get value => _props['value'] as bool? ?? false; - - /// An optional label to display next to the checkbox. - String? get label => _props['label'] as String?; -} - -/// A data object for accessing the properties of a `Radio` widget. -extension type UiRadio.fromMap(Map _json) { - Map get _props => _json['props'] as Map; - - /// The value that this radio button represents. - Object? get value => _props['value']; - - /// The currently selected value for the group of radio buttons this button - /// belongs to. - Object? get groupValue => _props['groupValue']; - - /// An optional label to display next to the radio button. - String? get label => _props['label'] as String?; -} - -/// A data object for accessing the properties of a `Slider` widget. -extension type UiSlider.fromMap(Map _json) { - Map get _props => _json['props'] as Map; - - /// The current value of the slider. - double get value => (_props['value'] as num).toDouble(); - - /// The minimum value of the slider. - double get min => (_props['min'] as num?)?.toDouble() ?? 0.0; - - /// The maximum value of the slider. - double get max => (_props['max'] as num?)?.toDouble() ?? 1.0; - - /// The number of discrete divisions on the slider. - int? get divisions => _props['divisions'] as int?; -} - -/// A data object for accessing the properties of an `Align` widget. -extension type UiAlign.fromMap(Map _json) { - Map get _props => _json['props'] as Map; - - /// The alignment of the child widget within the `Align` widget. - String? get alignment => _props['alignment'] as String?; - - /// The ID of the child widget to align. - String? get child => _props['child'] as String?; -} - -/// A data object for accessing the properties of a container widget like -/// `Column` or `Row`. -extension type UiContainer.fromMap(Map _json) { - Map get _props => _json['props'] as Map; - - /// The alignment of the children along the main axis. - String? get mainAxisAlignment => _props['mainAxisAlignment'] as String?; - - /// The alignment of the children along the cross axis. - String? get crossAxisAlignment => _props['crossAxisAlignment'] as String?; - - /// The list of child widget IDs. - List? get children => - (_props['children'] as List?)?.cast(); -} - -/// A data object for accessing the properties of an `ElevatedButton` widget. -extension type UiElevatedButton.fromMap(Map _json) { - Map get _props => _json['props'] as Map; - - /// The ID of the child widget to display inside the button. - String? get child => _props['child'] as String?; -} - -/// A data object for accessing the properties of a `Padding` widget. -extension type UiPadding.fromMap(Map _json) { - Map get _props => _json['props'] as Map; - - /// The padding to apply to the child widget. - UiEdgeInsets get padding => - UiEdgeInsets.fromMap(_props['padding'] as Map); - - /// The ID of the child widget to pad. - String? get child => _props['child'] as String?; -} - -/// A data object for representing edge insets (padding). -extension type UiEdgeInsets.fromMap(Map _json) { - /// The top padding value. - double get top => (_json['top'] as num?)?.toDouble() ?? 0.0; - - /// The left padding value. - double get left => (_json['left'] as num?)?.toDouble() ?? 0.0; - - /// The bottom padding value. - double get bottom => (_json['bottom'] as num?)?.toDouble() ?? 0.0; - - /// The right padding value. - double get right => (_json['right'] as num?)?.toDouble() ?? 0.0; -} From fce3f156f54776387ef86ba018957a84a6e55021 Mon Sep 17 00:00:00 2001 From: Jacob Simionato Date: Tue, 29 Jul 2025 12:17:38 +0930 Subject: [PATCH 10/10] Misc cleanups and test fixes --- pkgs/genui_client/lib/main.dart | 4 +- pkgs/genui_client/lib/src/widgets/text.dart | 2 +- pkgs/genui_client/test/dynamic_ui_test.dart | 97 +++++++ pkgs/genui_client/test/main_test.dart | 286 -------------------- 4 files changed, 100 insertions(+), 289 deletions(-) delete mode 100644 pkgs/genui_client/test/main_test.dart diff --git a/pkgs/genui_client/lib/main.dart b/pkgs/genui_client/lib/main.dart index 19bfefab4..0298b3c7e 100644 --- a/pkgs/genui_client/lib/main.dart +++ b/pkgs/genui_client/lib/main.dart @@ -155,8 +155,8 @@ class _GenUIHomePageState extends State { setState(() { _connectionStatus = status; if (status == 'Server started.' && _chatHistory.isEmpty) { - // _chatHistory.add(const SystemMessage( - // text: 'I can create UIs. What should I make for you?')); + _chatHistory + .add(const SystemMessage(text: 'What can I do for you?')); } }); }, diff --git a/pkgs/genui_client/lib/src/widgets/text.dart b/pkgs/genui_client/lib/src/widgets/text.dart index 887eae148..b47431eef 100644 --- a/pkgs/genui_client/lib/src/widgets/text.dart +++ b/pkgs/genui_client/lib/src/widgets/text.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import '../catalog_item.dart'; final text = CatalogItem( - name: 'Text', + name: 'text', dataSchema: Schema.object( properties: { 'text': Schema.string( diff --git a/pkgs/genui_client/test/dynamic_ui_test.dart b/pkgs/genui_client/test/dynamic_ui_test.dart index 8b1378917..2ee3f9837 100644 --- a/pkgs/genui_client/test/dynamic_ui_test.dart +++ b/pkgs/genui_client/test/dynamic_ui_test.dart @@ -1 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:genui_client/src/catalog.dart'; +import 'package:genui_client/src/widgets/elevated_button.dart'; +import 'package:genui_client/src/widgets/text.dart'; +import 'package:genui_client/src/dynamic_ui.dart'; +import 'package:genui_client/src/ui_models.dart'; +void main() { + final testCatalog = Catalog([elevatedButtonCatalogItem, text]); + + testWidgets('DynamicUi builds a widget from a definition', + (WidgetTester tester) async { + final definition = UiDefinition.fromMap({ + 'surfaceId': 'testSurface', + 'root': 'root', + 'widgets': [ + { + 'id': 'root', + 'widget': { + 'elevated_button': { + 'child': 'text', + }, + }, + }, + { + 'id': 'text', + 'widget': { + 'text': { + 'text': 'Hello', + }, + }, + }, + ], + }); + + await tester.pumpWidget( + MaterialApp( + home: DynamicUi( + catalog: testCatalog, + surfaceId: 'testSurface', + definition: definition, + onEvent: (event) {}, + ), + ), + ); + + expect(find.text('Hello'), findsOneWidget); + expect(find.byType(ElevatedButton), findsOneWidget); + }); + + testWidgets('DynamicUi handles events', (WidgetTester tester) async { + Map? event; + + final definition = UiDefinition.fromMap({ + 'surfaceId': 'testSurface', + 'root': 'root', + 'widgets': [ + { + 'id': 'root', + 'widget': { + 'elevated_button': { + 'child': 'text', + }, + }, + }, + { + 'id': 'text', + 'widget': { + 'text': { + 'text': 'Hello', + }, + }, + }, + ], + }); + + await tester.pumpWidget( + MaterialApp( + home: DynamicUi( + catalog: testCatalog, + surfaceId: 'testSurface', + definition: definition, + onEvent: (e) { + event = e; + }, + ), + ), + ); + + await tester.tap(find.byType(ElevatedButton)); + + expect(event, isNotNull); + expect(event!['surfaceId'], 'testSurface'); + expect(event!['widgetId'], 'root'); + expect(event!['eventType'], 'onTap'); + }); +} diff --git a/pkgs/genui_client/test/main_test.dart b/pkgs/genui_client/test/main_test.dart deleted file mode 100644 index 44eb5a3c4..000000000 --- a/pkgs/genui_client/test/main_test.dart +++ /dev/null @@ -1,286 +0,0 @@ -import 'package:fake_async/fake_async.dart'; -import 'package:firebase_ai/firebase_ai.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:genui_client/main.dart'; -import 'package:genui_client/src/ai_client/ai_client.dart'; -import 'package:genui_client/src/dynamic_ui.dart'; -import 'package:genui_client/src/tools/tools.dart'; - -void main() { - late AiClient fakeAiClient; - - setUp(() { - fakeAiClient = FakeAiClient(); - }); - - testWidgets('GenUIHomePage shows server started status after startup', - (WidgetTester tester) async { - await tester.pumpWidget(MaterialApp( - home: GenUIHomePage( - autoStartServer: true, - aiClient: fakeAiClient, - ), - )); - await tester.pumpAndSettle(); - expect(find.text('I can create UIs. What should I make for you?'), - findsOneWidget); - }); - - testWidgets('DynamicUi is created and handles events', - (WidgetTester tester) async { - await fakeAsync((async) async { - await tester.pumpWidget(MaterialApp( - home: GenUIHomePage( - autoStartServer: true, - aiClient: fakeAiClient, - ), - )); - await tester.pumpAndSettle(); - - // Enter a prompt and send it. - await tester.enterText(find.byType(TextField), 'A simple button'); - await tester.tap(find.byType(IconButton)); - await tester.pumpAndSettle(); - - expect(find.byType(DynamicUi), findsOneWidget); - expect(find.text('Click Me'), findsOneWidget); - - // Tap the button - await tester.tap(find.byType(ElevatedButton)); - await tester.pumpAndSettle(); - async.elapse(const Duration(seconds: 3)); - await tester.pumpAndSettle(); - expect(find.text('Button clicked!'), findsOneWidget); - }); - }); - - testWidgets('UI shows error when AI client throws an exception', - (WidgetTester tester) async { - await tester.pumpWidget(MaterialApp( - home: GenUIHomePage( - autoStartServer: true, - aiClient: fakeAiClient, - ), - )); - await tester.pumpAndSettle(); - - // Enter a prompt that will cause an error. - await tester.enterText(find.byType(TextField), 'An error'); - await tester.tap(find.byType(IconButton)); - await tester.pump(); - - // Pump until the error message appears, with a timeout. - for (var i = 0; i < 10; i++) { - if (tester.any(find.text('Error: Exception: Something went wrong'))) { - break; - } - await tester.pump(const Duration(milliseconds: 100)); - } - - expect(find.text('Error: Exception: Something went wrong'), findsOneWidget); - // Also make sure that the prompt is still there. - expect(find.text('An error'), findsOneWidget); - }); - - testWidgets('User prompt is added to chat history immediately', - (WidgetTester tester) async { - await tester.pumpWidget(MaterialApp( - home: GenUIHomePage( - autoStartServer: true, - aiClient: fakeAiClient, - ), - )); - await tester.pumpAndSettle(); - - // Enter a prompt and send it. - await tester.enterText(find.byType(TextField), 'A test prompt'); - await tester.tap(find.byType(IconButton)); - await tester.pump(); - - // Check that the prompt is displayed immediately. - expect(find.text('A test prompt'), findsOneWidget); - - // Let the AI "respond". - await tester.pumpAndSettle(); - - // Check that the prompt is still there, and the response is there too. - expect(find.text('A test prompt'), findsOneWidget); - expect(find.byType(DynamicUi), findsOneWidget); - }); - - testWidgets('Chat history is maintained', (WidgetTester tester) async { - await tester.pumpWidget(MaterialApp( - home: GenUIHomePage( - autoStartServer: true, - aiClient: fakeAiClient, - ), - )); - await tester.pumpAndSettle(); - - // First prompt and response - await tester.enterText(find.byType(TextField), 'First prompt'); - await tester.tap(find.byType(IconButton)); - await tester.pumpAndSettle(); - - expect(find.text('First prompt'), findsOneWidget); - expect(find.text('Response to "First prompt"'), findsOneWidget); - - // Second prompt and response - await tester.enterText(find.byType(TextField), 'Second prompt'); - await tester.tap(find.byType(IconButton)); - await tester.pumpAndSettle(); - - expect(find.text('Second prompt'), findsOneWidget); - expect(find.text('Response to "Second prompt"'), findsOneWidget); - - // Check that the first prompt and response are still there. - expect(find.text('First prompt'), findsOneWidget); - expect(find.text('Response to "First prompt"'), findsOneWidget); - }); - - testWidgets('Chat scrolls to bottom on new message', - (WidgetTester tester) async { - await tester.pumpWidget(MaterialApp( - home: GenUIHomePage( - autoStartServer: true, - aiClient: fakeAiClient, - ), - )); - await tester.pumpAndSettle(); - - final scrollController = - tester.widget(find.byType(ListView)).controller!; - - // Add enough content to make the list scrollable. - for (var i = 0; i < 10; i++) { - await tester.enterText(find.byType(TextField), 'Prompt $i'); - await tester.tap(find.byType(IconButton)); - await tester.pumpAndSettle(); - } - - // Check that we're scrolled to the bottom. - expect(scrollController.position.pixels, - scrollController.position.maxScrollExtent); - - // Add one more message and check that we're still at the bottom. - await tester.enterText(find.byType(TextField), 'Last prompt'); - await tester.tap(find.byType(IconButton)); - await tester.pumpAndSettle(); - expect(scrollController.position.pixels, - scrollController.position.maxScrollExtent); - }); -} - -class FakeAiClient implements AiClient { - @override - Future generateContent( - List prompts, - Schema outputSchema, { - Iterable additionalTools = const [], - Content? systemInstruction, - }) async { - final lastContent = prompts.last; - if (prompts.any((p) => p.role == 'function')) { - final response = { - 'actions': [ - { - 'action': 'update', - 'surfaceId': 'surface_0', - 'definition': { - 'root': 'button', - 'widgets': [ - { - 'id': 'button', - 'type': 'ElevatedButton', - 'props': {'child': 'text'}, - }, - { - 'id': 'text', - 'type': 'Text', - 'props': {'data': 'Button clicked!'}, - }, - ], - } - } - ] - }; - return response as T; - } - if (lastContent.role == 'user') { - final lastPart = lastContent.parts.first as TextPart; - if (lastPart.text.contains('error')) { - throw Exception('Something went wrong'); - } - if (lastPart.text.contains('button')) { - final response = { - 'actions': [ - { - 'action': 'add', - 'surfaceId': 'surface_0', - 'definition': { - 'root': 'button', - 'widgets': [ - { - 'id': 'button', - 'type': 'ElevatedButton', - 'props': {'child': 'text'}, - }, - { - 'id': 'text', - 'type': 'Text', - 'props': {'data': 'Click Me'}, - }, - ], - } - } - ] - }; - return response as T; - } - final response = { - 'actions': [ - { - 'action': 'add', - 'surfaceId': 'surface_0', - 'definition': { - 'root': 'root', - 'widgets': [ - { - 'id': 'root', - 'type': 'Text', - 'props': {'data': 'Response to "${lastPart.text}"'}, - }, - ], - } - } - ] - }; - return response as T; - } - final response = { - 'actions': [ - { - 'action': 'add', - 'surfaceId': 'surface_0', - 'definition': { - 'root': 'root', - 'widgets': [ - { - 'id': 'root', - 'type': 'Text', - 'props': {'data': 'A simple response'}, - }, - ], - } - } - ] - }; - return response as T; - } - - @override - dynamic noSuchMethod(Invocation invocation) { - return super.noSuchMethod(invocation); - } -}