diff --git a/examples/catalog_gallery/lib/main.dart b/examples/catalog_gallery/lib/main.dart index 186b33351..777ce43b2 100644 --- a/examples/catalog_gallery/lib/main.dart +++ b/examples/catalog_gallery/lib/main.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:convert'; + import 'package:flutter/material.dart'; import 'package:flutter_genui/flutter_genui.dart'; @@ -9,11 +11,16 @@ void main() { runApp(CatalogGalleryApp(CoreCatalogItems.asCatalog())); } -class CatalogGalleryApp extends StatelessWidget { +class CatalogGalleryApp extends StatefulWidget { const CatalogGalleryApp(this.catalog, {super.key}); final Catalog catalog; + @override + State createState() => _CatalogGalleryAppState(); +} + +class _CatalogGalleryAppState extends State { @override Widget build(BuildContext context) { return MaterialApp( @@ -25,7 +32,16 @@ class CatalogGalleryApp extends StatelessWidget { backgroundColor: Theme.of(context).colorScheme.inversePrimary, title: const Text('Catalog items that has "exampleData" field set'), ), - body: DebugCatalogView(catalog: catalog), + body: DebugCatalogView( + catalog: widget.catalog, + onSubmit: (message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('User action: ${jsonEncode(message.parts.last)}'), + ), + ); + }, + ), ), ); } diff --git a/examples/simple_chat/lib/main.dart b/examples/simple_chat/lib/main.dart index ffb60be24..f08a3d857 100644 --- a/examples/simple_chat/lib/main.dart +++ b/examples/simple_chat/lib/main.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:flutter_genui/flutter_genui.dart'; @@ -141,7 +143,7 @@ class _ChatScreenState extends State { ); } - Future _sendMessage() async { + void _sendMessage() { final text = _textController.text; if (text.isEmpty) { return; @@ -154,7 +156,7 @@ class _ChatScreenState extends State { _scrollToBottom(); - await _uiAgent.sendRequest(UserMessage([TextPart(text)])); + unawaited(_uiAgent.sendRequest(UserMessage([TextPart(text)]))); } void _scrollToBottom() { diff --git a/examples/travel_app/IMPLEMENTATION.md b/examples/travel_app/IMPLEMENTATION.md index d6f169a6f..f08603803 100644 --- a/examples/travel_app/IMPLEMENTATION.md +++ b/examples/travel_app/IMPLEMENTATION.md @@ -102,9 +102,13 @@ This is the collection of predefined UI components that the AI can use to constr - `TravelCarousel`: For displaying a horizontal list of selectable options. - `ItineraryWithDetails`, `ItineraryDay`, `ItineraryEntry`: For building structured travel plans. - `InputGroup`: A container for grouping various input widgets. - - `OptionsFilterChipInput`, `CheckboxFilterChipsInput`, `TextInputChip`: Different types of input chips for user selections. + - `OptionsFilterChipInput`, `CheckboxFilterChipsInput`, `TextInputChip`, `DateInputChip`: Different types of input chips for user selections. - `InformationCard`: For displaying detailed information about a topic. - `Trailhead`: For suggesting follow-up prompts to the user. + - `ListingsBooker`: For displaying a list of bookings to checkout. + - `PaddedBodyText`: For displaying a block of text with padding. + - `SectionHeader`: For displaying a section header. + - `TabbedSections`: For displaying a set of tabbed sections. - **Standard Components**: It also uses standard, pre-built components from `flutter_genui` like `column`, `text`, `image`, etc. ## Data Flow: The Generative UI Cycle diff --git a/examples/travel_app/README.md b/examples/travel_app/README.md index 31e2b31d9..21d813457 100644 --- a/examples/travel_app/README.md +++ b/examples/travel_app/README.md @@ -21,8 +21,8 @@ All of the UI is generated dynamically and streamed into a chat-like view, creat This example highlights several core concepts of the `flutter_genui` package: - **Dynamic UI Generation**: The entire user interface is constructed on-the-fly by the AI based on the conversation. -- **Component Catalog**: The AI builds the UI from a custom, domain-specific catalog of widgets defined in `lib/src/catalog.dart`. This includes widgets like `travel_carousel`, `itinerary_item`, and `options_filter_chip`. -- **System Prompt Engineering**: The behavior of the AI is guided by a detailed system prompt located in `lib/main.dart`. This prompt instructs the AI on how to act like a travel agent and which widgets to use in various scenarios. +- **Component Catalog**: The AI builds the UI from a custom, domain-specific catalog of widgets defined in `lib/src/catalog.dart`. This includes widgets like `TravelCarousel`, `ItineraryEntry`, and `OptionsFilterChipInput`. +- **System Prompt Engineering**: The behavior of the AI is guided by a detailed system prompt located in `lib/src/travel_planner_page.dart`. This prompt instructs the AI on how to act like a travel agent and which widgets to use in various scenarios. - **Dynamic UI State Management**: The `GenUiManager` from `flutter_genui` handles the state of the dynamically generated UI surfaces, manages the widget tree, and processes events between the user and the AI. The application's main page (`TravelPlannerPage`) manages the overall conversation history. - **Firebase Integration**: The application is configured to use Firebase for backend services, as shown in `lib/firebase_options.dart`. diff --git a/examples/travel_app/lib/src/catalog/input_group.dart b/examples/travel_app/lib/src/catalog/input_group.dart index 9157faa07..d1dacf8f7 100644 --- a/examples/travel_app/lib/src/catalog/input_group.dart +++ b/examples/travel_app/lib/src/catalog/input_group.dart @@ -17,21 +17,31 @@ final _schema = S.object( 'be input types such as OptionsFilterChipInput.', items: S.string(), ), + 'action': A2uiSchemas.action( + description: + 'The action to perform when the submit button is pressed. ' + 'The context for this action should include references to the values ' + 'of all the input chips inside this group, so that the model can ' + 'know what the user has selected.', + ), }, - required: ['submitLabel', 'children'], + required: ['submitLabel', 'children', 'action'], ); extension type _InputGroupData.fromMap(Map _json) { factory _InputGroupData({ required JsonMap submitLabel, required List children, + required JsonMap action, }) => _InputGroupData.fromMap({ 'submitLabel': submitLabel, 'children': children, + 'action': action, }); JsonMap get submitLabel => _json['submitLabel'] as JsonMap; List get children => (_json['children'] as List).cast(); + JsonMap get action => _json['action'] as JsonMap; } /// A container widget that visually groups a collection of input chips. @@ -110,6 +120,10 @@ final inputGroup = CatalogItem( ); final children = inputGroupData.children; + final actionData = inputGroupData.action; + final actionName = actionData['actionName'] as String; + final contextDefinition = + (actionData['context'] as List?) ?? []; return Card( color: Theme.of(context).colorScheme.primaryContainer, @@ -128,13 +142,19 @@ final inputGroup = CatalogItem( valueListenable: notifier, builder: (context, submitLabel, child) { return ElevatedButton( - onPressed: () => dispatchEvent( - UiActionEvent( - widgetId: id, - eventType: 'submit', - value: {}, - ), - ), + onPressed: () { + final resolvedContext = resolveContext( + dataContext, + contextDefinition, + ); + dispatchEvent( + UserActionEvent( + actionName: actionName, + sourceComponentId: id, + context: resolvedContext, + ), + ); + }, child: Text(submitLabel ?? ''), style: ElevatedButton.styleFrom( backgroundColor: Theme.of(context).colorScheme.primary, diff --git a/examples/travel_app/lib/src/catalog/itinerary_entry.dart b/examples/travel_app/lib/src/catalog/itinerary_entry.dart index 830e6b8e4..ee9b62b71 100644 --- a/examples/travel_app/lib/src/catalog/itinerary_entry.dart +++ b/examples/travel_app/lib/src/catalog/itinerary_entry.dart @@ -51,6 +51,12 @@ final _schema = S.object( 'is confirmed.', enumValues: ItineraryEntryStatus.values.map((e) => e.name).toList(), ), + 'choiceRequiredAction': A2uiSchemas.action( + description: + 'The action to perform when the user needs to make a choice. ' + 'This is only used when the status is "choiceRequired". The context ' + 'for this action should include the title of this itinerary entry.', + ), }, required: ['title', 'bodyText', 'time', 'type', 'status'], ); @@ -65,6 +71,7 @@ extension type _ItineraryEntryData.fromMap(Map _json) { JsonMap? totalCost, required String type, required String status, + JsonMap? choiceRequiredAction, }) => _ItineraryEntryData.fromMap({ 'title': title, if (subtitle != null) 'subtitle': subtitle, @@ -74,6 +81,8 @@ extension type _ItineraryEntryData.fromMap(Map _json) { if (totalCost != null) 'totalCost': totalCost, 'type': type, 'status': status, + if (choiceRequiredAction != null) + 'choiceRequiredAction': choiceRequiredAction, }); JsonMap get title => _json['title'] as JsonMap; @@ -86,6 +95,8 @@ extension type _ItineraryEntryData.fromMap(Map _json) { ItineraryEntryType.values.byName(_json['type'] as String); ItineraryEntryStatus get status => ItineraryEntryStatus.values.byName(_json['status'] as String); + JsonMap? get choiceRequiredAction => + _json['choiceRequiredAction'] as JsonMap?; } final itineraryEntry = CatalogItem( @@ -132,8 +143,10 @@ final itineraryEntry = CatalogItem( totalCostNotifier: totalCostNotifier, type: itineraryEntryData.type, status: itineraryEntryData.status, + choiceRequiredAction: itineraryEntryData.choiceRequiredAction, widgetId: id, dispatchEvent: dispatchEvent, + dataContext: dataContext, ); }, ); @@ -147,8 +160,10 @@ class _ItineraryEntry extends StatelessWidget { final ValueNotifier totalCostNotifier; final ItineraryEntryType type; final ItineraryEntryStatus status; + final JsonMap? choiceRequiredAction; final String widgetId; final DispatchEventCallback dispatchEvent; + final DataContext dataContext; const _ItineraryEntry({ required this.titleNotifier, @@ -159,8 +174,10 @@ class _ItineraryEntry extends StatelessWidget { required this.totalCostNotifier, required this.type, required this.status, + this.choiceRequiredAction, required this.widgetId, required this.dispatchEvent, + required this.dataContext, }); IconData _getIconForType(ItineraryEntryType type) { @@ -178,7 +195,7 @@ class _ItineraryEntry extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); return Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -207,13 +224,24 @@ class _ItineraryEntry extends StatelessWidget { valueListenable: titleNotifier, builder: (context, title, _) => FilledButton( onPressed: () { + final actionData = choiceRequiredAction; + if (actionData == null) { + return; + } + final actionName = + actionData['actionName'] as String; + final contextDefinition = + (actionData['context'] as List?) ?? + []; + final resolvedContext = resolveContext( + dataContext, + contextDefinition, + ); dispatchEvent( - UiActionEvent( - widgetId: widgetId, - eventType: 'seeOptions', - value: - 'Choose options in order to book ' - '${title ?? ''}', + UserActionEvent( + actionName: actionName, + sourceComponentId: widgetId, + context: resolvedContext, ), ); DismissNotification().dispatch(context); diff --git a/examples/travel_app/lib/src/catalog/itinerary_with_details.dart b/examples/travel_app/lib/src/catalog/itinerary_with_details.dart index e28a71124..21484146a 100644 --- a/examples/travel_app/lib/src/catalog/itinerary_with_details.dart +++ b/examples/travel_app/lib/src/catalog/itinerary_with_details.dart @@ -189,9 +189,7 @@ class _ItineraryWithDetails extends StatelessWidget { child: Row( children: [ ClipRRect( - borderRadius: BorderRadius.circular( - 8.0, - ), // Adjust radius as needed + borderRadius: BorderRadius.circular(8.0), child: SizedBox(height: 100, width: 100, child: imageChild), ), const SizedBox(width: 8.0), diff --git a/examples/travel_app/lib/src/catalog/listings_booker.dart b/examples/travel_app/lib/src/catalog/listings_booker.dart index f5222d83a..1c7b0d53b 100644 --- a/examples/travel_app/lib/src/catalog/listings_booker.dart +++ b/examples/travel_app/lib/src/catalog/listings_booker.dart @@ -11,15 +11,21 @@ import '../tools/booking/booking_service.dart'; import '../tools/booking/model.dart'; final _schema = S.object( - description: 'A widget to check out set of listings.', + description: 'A widget to select among a set of listings.', properties: { 'listingSelectionIds': S.list( - description: 'Listings to checkout.', + description: 'Listings to select among.', items: S.string(), ), 'itineraryName': A2uiSchemas.stringReference( description: 'The name of the itinerary.', ), + 'modifyAction': A2uiSchemas.action( + description: + 'The action to perform when the user wants to modify a listing ' + 'selection. The listingSelectionId will be added to the context with ' + 'the key "listingSelectionId".', + ), }, required: ['listingSelectionIds'], ); @@ -28,14 +34,17 @@ extension type _ListingsBookerData.fromMap(Map _json) { factory _ListingsBookerData({ required List listingSelectionIds, required JsonMap itineraryName, + JsonMap? modifyAction, }) => _ListingsBookerData.fromMap({ 'listingSelectionIds': listingSelectionIds, 'itineraryName': itineraryName, + if (modifyAction != null) 'modifyAction': modifyAction, }); List get listingSelectionIds => (_json['listingSelectionIds'] as List).cast(); JsonMap get itineraryName => _json['itineraryName'] as JsonMap; + JsonMap? get modifyAction => _json['modifyAction'] as JsonMap?; } final listingsBooker = CatalogItem( @@ -66,6 +75,8 @@ final listingsBooker = CatalogItem( itineraryName: itineraryName ?? '', dispatchEvent: dispatchEvent, widgetId: id, + modifyAction: listingsBookerData.modifyAction, + dataContext: dataContext, ); }, ); @@ -136,12 +147,16 @@ class _ListingsBooker extends StatefulWidget { final String itineraryName; final DispatchEventCallback dispatchEvent; final String widgetId; + final JsonMap? modifyAction; + final DataContext dataContext; const _ListingsBooker({ required this.listingSelectionIds, required this.itineraryName, required this.dispatchEvent, required this.widgetId, + this.modifyAction, + required this.dataContext, }); @override @@ -328,14 +343,26 @@ class _ListingsBookerState extends State<_ListingsBooker> { const SizedBox(width: 8), TextButton( onPressed: () { + final actionData = widget.modifyAction; + if (actionData == null) { + return; + } + final actionName = + actionData['actionName'] as String; + final contextDefinition = + (actionData['context'] as List?) ?? + []; + final resolvedContext = resolveContext( + widget.dataContext, + contextDefinition, + ); + resolvedContext['listingSelectionId'] = + listing.listingSelectionId; widget.dispatchEvent( - UiActionEvent( - eventType: 'Modify', - widgetId: widget.widgetId, - value: { - 'listingSelectionId': - listing.listingSelectionId, - }, + UserActionEvent( + actionName: actionName, + sourceComponentId: widget.widgetId, + context: resolvedContext, ), ); }, diff --git a/examples/travel_app/lib/src/catalog/trailhead.dart b/examples/travel_app/lib/src/catalog/trailhead.dart index f866ef09e..30254841f 100644 --- a/examples/travel_app/lib/src/catalog/trailhead.dart +++ b/examples/travel_app/lib/src/catalog/trailhead.dart @@ -12,15 +12,23 @@ final _schema = S.object( description: 'A list of topics to display as chips.', items: A2uiSchemas.stringReference(description: 'A topic to explore.'), ), + 'action': A2uiSchemas.action( + description: + 'The action to perform when a topic is selected. The selected topic ' + 'will be added to the context with the key "topic".', + ), }, - required: ['topics'], + required: ['topics', 'action'], ); extension type _TrailheadData.fromMap(Map _json) { - factory _TrailheadData({required List topics}) => - _TrailheadData.fromMap({'topics': topics}); + factory _TrailheadData({ + required List topics, + required JsonMap action, + }) => _TrailheadData.fromMap({'topics': topics, 'action': action}); List get topics => (_json['topics'] as List).cast(); + JsonMap get action => _json['action'] as JsonMap; } /// A widget that presents a list of suggested topics or follow-up questions to @@ -49,6 +57,7 @@ final trailhead = CatalogItem( ); return _Trailhead( topics: trailheadData.topics, + action: trailheadData.action, widgetId: id, dispatchEvent: dispatchEvent, dataContext: dataContext, @@ -59,12 +68,14 @@ final trailhead = CatalogItem( class _Trailhead extends StatelessWidget { const _Trailhead({ required this.topics, + required this.action, required this.widgetId, required this.dispatchEvent, required this.dataContext, }); final List topics; + final JsonMap action; final String widgetId; final DispatchEventCallback dispatchEvent; final DataContext dataContext; @@ -88,11 +99,19 @@ class _Trailhead extends StatelessWidget { return InputChip( label: Text(topic), onPressed: () { + final actionName = action['actionName'] as String; + final contextDefinition = + (action['context'] as List?) ?? []; + final resolvedContext = resolveContext( + dataContext, + contextDefinition, + ); + resolvedContext['topic'] = topic; dispatchEvent( - UiActionEvent( - widgetId: widgetId, - eventType: 'trailheadTopicSelected', - value: topic, + UserActionEvent( + actionName: actionName, + sourceComponentId: widgetId, + context: resolvedContext, ), ); }, diff --git a/examples/travel_app/lib/src/catalog/travel_carousel.dart b/examples/travel_app/lib/src/catalog/travel_carousel.dart index 13ba06582..70a9bd529 100644 --- a/examples/travel_app/lib/src/catalog/travel_carousel.dart +++ b/examples/travel_app/lib/src/catalog/travel_carousel.dart @@ -38,8 +38,14 @@ final _schema = S.object( 'represents. This is useful when the carousel is used to show ' 'a list of hotels or other bookable items.', ), + 'action': A2uiSchemas.action( + description: + 'The action to perform when the item is tapped. The ' + 'context for this action will include the "description" and ' + '"listingSelectionId" of the tapped item.', + ), }, - required: ['description', 'imageChildId'], + required: ['description', 'imageChildId', 'action'], ), ), }, @@ -82,6 +88,7 @@ final travelCarousel = CatalogItem( descriptionNotifier: descriptionNotifier, imageChild: buildChild(item.imageChildId), listingSelectionId: item.listingSelectionId, + action: item.action, ); }).toList(); @@ -93,6 +100,7 @@ final travelCarousel = CatalogItem( items: items, widgetId: id, dispatchEvent: dispatchEvent, + dataContext: dataContext, ); }, ); @@ -124,15 +132,18 @@ extension type _TravelCarouselItemSchemaData.fromMap( required JsonMap description, required String imageChildId, String? listingSelectionId, + required JsonMap action, }) => _TravelCarouselItemSchemaData.fromMap({ 'description': description, 'imageChildId': imageChildId, if (listingSelectionId != null) 'listingSelectionId': listingSelectionId, + 'action': action, }); JsonMap get description => _json['description'] as JsonMap; String get imageChildId => _json['imageChildId'] as String; String? get listingSelectionId => _json['listingSelectionId'] as String?; + JsonMap get action => _json['action'] as JsonMap; } class _DesktopAndWebScrollBehavior extends MaterialScrollBehavior { @@ -149,12 +160,14 @@ class _TravelCarousel extends StatelessWidget { required this.items, required this.widgetId, required this.dispatchEvent, + required this.dataContext, }); final String? title; final List<_TravelCarouselItemData> items; final String widgetId; final DispatchEventCallback dispatchEvent; + final DataContext dataContext; @override Widget build(BuildContext context) { @@ -184,6 +197,7 @@ class _TravelCarousel extends StatelessWidget { data: items[index], widgetId: widgetId, dispatchEvent: dispatchEvent, + dataContext: dataContext, ); }, separatorBuilder: (context, index) => const SizedBox(width: 16), @@ -199,11 +213,13 @@ class _TravelCarouselItemData { final ValueNotifier descriptionNotifier; final Widget imageChild; final String? listingSelectionId; + final JsonMap action; _TravelCarouselItemData({ required this.descriptionNotifier, required this.imageChild, this.listingSelectionId, + required this.action, }); } @@ -212,11 +228,13 @@ class _TravelCarouselItem extends StatelessWidget { required this.data, required this.widgetId, required this.dispatchEvent, + required this.dataContext, }); final _TravelCarouselItemData data; final String widgetId; final DispatchEventCallback dispatchEvent; + final DataContext dataContext; @override Widget build(BuildContext context) { @@ -224,15 +242,22 @@ class _TravelCarouselItem extends StatelessWidget { width: 190, child: InkWell( onTap: () { + final actionName = data.action['actionName'] as String; + final contextDefinition = + (data.action['context'] as List?) ?? []; + final resolvedContext = resolveContext( + dataContext, + contextDefinition, + ); + resolvedContext['description'] = data.descriptionNotifier.value; + if (data.listingSelectionId != null) { + resolvedContext['listingSelectionId'] = data.listingSelectionId; + } dispatchEvent( - UiActionEvent( - widgetId: widgetId, - eventType: 'itemSelected', - value: { - 'description': data.descriptionNotifier.value, - if (data.listingSelectionId != null) - 'listingSelectionId': data.listingSelectionId, - }, + UserActionEvent( + actionName: actionName, + sourceComponentId: widgetId, + context: resolvedContext, ), ); }, @@ -292,11 +317,13 @@ JsonMap _hotelExample() { 'description': {'literalString': hotel1.description}, 'imageChildId': 'image_1', 'listingSelectionId': '12345', + 'action': {'actionName': 'selectHotel'}, }, { 'description': {'literalString': hotel2.description}, 'imageChildId': 'image_2', 'listingSelectionId': '12346', + 'action': {'actionName': 'selectHotel'}, }, ], }, @@ -357,20 +384,24 @@ JsonMap _inspirationExample() => { 'description': {'literalString': 'Relaxing Beach Holiday'}, 'imageChildId': 'santorini_beach_image', 'listingSelectionId': '12345', + 'action': {'actionName': 'selectExperience'}, }, { 'imageChildId': 'akrotiri_fresco_image', 'description': {'literalString': 'Cultural Exploration'}, 'listingSelectionId': '12346', + 'action': {'actionName': 'selectExperience'}, }, { 'imageChildId': 'santorini_caldera_image', 'description': {'literalString': 'Adventure & Outdoors'}, 'listingSelectionId': '12347', + 'action': {'actionName': 'selectExperience'}, }, { 'description': {'literalString': 'Foodie Tour'}, 'imageChildId': 'greece_food_image', + 'action': {'actionName': 'selectExperience'}, }, ], }, diff --git a/examples/travel_app/test/input_group_test.dart b/examples/travel_app/test/input_group_test.dart index 57283bcfa..697857847 100644 --- a/examples/travel_app/test/input_group_test.dart +++ b/examples/travel_app/test/input_group_test.dart @@ -15,6 +15,7 @@ void main() { final data = { 'submitLabel': {'literalString': 'Submit'}, 'children': ['child1', 'child2'], + 'action': {'actionName': 'submitAction'}, }; UiEvent? dispatchedEvent; @@ -49,9 +50,10 @@ void main() { // Verify that the submit event is dispatched on tap. await tester.tap(button); - expect(dispatchedEvent, isA()); - expect(dispatchedEvent?.eventType, 'submit'); - expect((dispatchedEvent as UiActionEvent).widgetId, 'testId'); + expect(dispatchedEvent, isA()); + final actionEvent = dispatchedEvent as UserActionEvent; + expect(actionEvent.actionName, 'submitAction'); + expect(actionEvent.sourceComponentId, 'testId'); }, ); @@ -61,6 +63,7 @@ void main() { final data = { 'submitLabel': {'literalString': 'Submit'}, 'children': [], + 'action': {'actionName': 'submitAction'}, }; await tester.pumpWidget( diff --git a/examples/travel_app/test/trailhead_test.dart b/examples/travel_app/test/trailhead_test.dart index d3ec6f498..7581c4f77 100644 --- a/examples/travel_app/test/trailhead_test.dart +++ b/examples/travel_app/test/trailhead_test.dart @@ -17,6 +17,7 @@ void main() { {'literalString': 'Topic A'}, {'literalString': 'Topic B'}, ], + 'action': {'actionName': 'selectTopic'}, }; UiEvent? dispatchedEvent; @@ -47,17 +48,20 @@ void main() { await tester.tap(find.text('Topic A')); await tester.pump(); - expect(dispatchedEvent, isA()); - final actionEvent = dispatchedEvent as UiActionEvent; - expect(actionEvent.widgetId, 'testId'); - expect(actionEvent.eventType, 'trailheadTopicSelected'); - expect(actionEvent.value, 'Topic A'); + expect(dispatchedEvent, isA()); + final actionEvent = dispatchedEvent as UserActionEvent; + expect(actionEvent.sourceComponentId, 'testId'); + expect(actionEvent.actionName, 'selectTopic'); + expect(actionEvent.context, {'topic': 'Topic A'}); }); testWidgets('builds widget correctly with no topics', ( WidgetTester tester, ) async { - final data = {'topics': >[]}; + final data = { + 'topics': >[], + 'action': {'actionName': 'selectTopic'}, + }; await tester.pumpWidget( MaterialApp( diff --git a/examples/travel_app/test/travel_carousel_test.dart b/examples/travel_app/test/travel_carousel_test.dart index caad0769d..67422719d 100644 --- a/examples/travel_app/test/travel_carousel_test.dart +++ b/examples/travel_app/test/travel_carousel_test.dart @@ -19,10 +19,12 @@ void main() { { 'description': {'literalString': 'Item 1'}, 'imageChildId': 'imageId1', + 'action': {'actionName': 'selectItem'}, }, { 'description': {'literalString': 'Item 2'}, 'imageChildId': 'imageId2', + 'action': {'actionName': 'selectItem'}, }, ], }; @@ -60,11 +62,11 @@ void main() { await tester.tap(find.text('Item 1')); await tester.pump(); - expect(dispatchedEvent, isA()); - final actionEvent = dispatchedEvent as UiActionEvent; - expect(actionEvent.widgetId, 'testId'); - expect(actionEvent.eventType, 'itemSelected'); - expect(actionEvent.value, {'description': 'Item 1'}); + expect(dispatchedEvent, isA()); + final actionEvent = dispatchedEvent as UserActionEvent; + expect(actionEvent.sourceComponentId, 'testId'); + expect(actionEvent.actionName, 'selectItem'); + expect(actionEvent.context, {'description': 'Item 1'}); }); }); @@ -78,10 +80,12 @@ void main() { 'description': {'literalString': 'Item 1'}, 'imageChildId': 'imageId1', 'listingSelectionId': 'listing1', + 'action': {'actionName': 'selectItem'}, }, { 'description': {'literalString': 'Item 2'}, 'imageChildId': 'imageId2', + 'action': {'actionName': 'selectItem'}, }, ], }; @@ -115,8 +119,8 @@ void main() { await tester.tap(find.text('Item 1')); await tester.pump(); - final actionEvent = dispatchedEvent as UiActionEvent; - expect(actionEvent.value, { + final actionEvent = dispatchedEvent as UserActionEvent; + expect(actionEvent.context, { 'description': 'Item 1', 'listingSelectionId': 'listing1', }); diff --git a/packages/flutter_genui/IMPLEMENTATION.md b/packages/flutter_genui/IMPLEMENTATION.md index ce8777856..f963bdd91 100644 --- a/packages/flutter_genui/IMPLEMENTATION.md +++ b/packages/flutter_genui/IMPLEMENTATION.md @@ -75,7 +75,7 @@ This layer defines the data structures that represent the dynamic UI and the con - **`Catalog` and `CatalogItem`**: These classes define the registry of available UI components. The `Catalog` holds a list of `CatalogItem`s, and each `CatalogItem` defines a widget's name, its data schema, and a builder function to render it. - **`UiDefinition` and `UiEvent`**: `UiDefinition` represents a complete UI tree to be rendered, including the root widget and a map of all widget definitions. `UiEvent` is a data object representing a user interaction. `UiActionEvent` is a subtype used for events that should trigger a submission to the AI, like a button tap. -- **`ChatMessage`**: A sealed class representing the different types of messages in a conversation: `UserMessage`, `AiTextMessage`, `ToolResponseMessage`, `AiUiMessage`, and `InternalMessage`. +- **`ChatMessage`**: A sealed class representing the different types of messages in a conversation: `UserMessage`, `AiTextMessage`, `ToolResponseMessage`, `AiUiMessage`, `InternalMessage`, and `UserUiInteractionMessage`. - **`DataModel` and `DataContext`**: The `DataModel` is a centralized, observable key-value store that holds the entire dynamic state of the UI. Widgets receive a `DataContext`, which is a view into the `DataModel` that understands the widget's current scope. This allows widgets to subscribe to changes in the data model and rebuild reactively. This separation of data and UI structure is a core principle of the architecture. ### 4. Widget Catalog Layer (`lib/src/catalog/`) @@ -106,7 +106,8 @@ sequenceDiagram participant GenUiManager participant GenUiSurface as "GenUiSurface" - AppLogic->>+UiAgent: Initializes UiAgent with an instruction + AppLogic->>+UiAgent: Creates UiAgent(genUiManager, aiClient) + AppLogic->>+UiAgent: (Optional) sendRequest(instructionMessage) User->>+AppLogic: Provides input (e.g., text prompt) AppLogic->>+UiAgent: Calls sendRequest(userMessage) @@ -137,7 +138,7 @@ sequenceDiagram Note over UiAgent, LLM: The cycle repeats... ``` -1. **Initialization**: The developer creates a `UiAgent`, providing a system instruction. The `UiAgent` internally creates a `GenUiManager` and an `AiClient`. +1. **Initialization**: The developer creates a `UiAgent`, providing it with a `GenUiManager` and an `AiClient`. The developer may also provide a system instruction to the `UiAgent` by sending an an initial `UserMessage`. 2. **User Input**: The user enters a prompt. 3. **Send Request**: The developer calls `uiAgent.sendRequest(UserMessage.text(prompt))`. 4. **Conversation Management**: The `UiAgent` adds the `UserMessage` to its internal conversation history. @@ -147,5 +148,5 @@ sequenceDiagram 8. **Notification**: The `GenUiManager` updates its internal state (the `UiDefinition` for the surface) and broadcasts a `GenUiUpdate` event on its `surfaceUpdates` stream. 9. **UI Rendering**: A `GenUiSurface` widget listening to the `GenUiManager` (via the `GenUiHost` interface) receives the update and rebuilds, rendering the new UI based on the updated `UiDefinition`. 10. **User Interaction**: The user interacts with the newly generated UI (e.g., clicks a submit button). -11. **Event Dispatch**: The `GenUiSurface`'s `onEvent` callback is fired, which in turn calls `host.handleUiEvent()`. +11. **Event Dispatch**: The widget's builder calls a `dispatchEvent` function, which causes the `GenUiSurface` to call `host.handleUiEvent()`. 12. **Cycle Repeats**: The `GenUiManager`'s `handleUiEvent` method creates a `UserMessage` containing the state of the widgets on the surface (from its `DataModel`) and emits it on its `onSubmit` stream. The `UiAgent` is listening to this stream, receives the message, adds it to the conversation, and calls the AI again, thus continuing the cycle. diff --git a/packages/flutter_genui/README.md b/packages/flutter_genui/README.md index 7e7c3e59b..efbea7fa4 100644 --- a/packages/flutter_genui/README.md +++ b/packages/flutter_genui/README.md @@ -101,11 +101,7 @@ class _MyAppState extends State { final update = _updates[index]; return GenUiSurface( host: _uiAgent.host, - surfaceId: update.surfaceId, - onEvent: (event) { - // 3. The UiAgent handles events automatically - }, - ); + surfaceId: update.surfaceId, ); }, ), ), diff --git a/packages/flutter_genui/lib/src/catalog/core_widgets/checkbox_group.dart b/packages/flutter_genui/lib/src/catalog/core_widgets/checkbox_group.dart index 6a4d7f777..bded46dd0 100644 --- a/packages/flutter_genui/lib/src/catalog/core_widgets/checkbox_group.dart +++ b/packages/flutter_genui/lib/src/catalog/core_widgets/checkbox_group.dart @@ -99,6 +99,22 @@ class _CheckboxGroupState extends State<_CheckboxGroup> { final checkboxGroup = CatalogItem( name: 'CheckboxGroup', dataSchema: _schema, + exampleData: [ + () => { + 'root': 'checkbox_group', + 'widgets': [ + { + 'id': 'checkbox_group', + 'widget': { + 'CheckboxGroup': { + 'selectedValues': {'path': 'selected'}, + 'labels': ['Option 1', 'Option 2', 'Option 3'], + }, + }, + }, + ], + }, + ], widgetBuilder: ({ required data, diff --git a/packages/flutter_genui/lib/src/catalog/core_widgets/column.dart b/packages/flutter_genui/lib/src/catalog/core_widgets/column.dart index 66fdf409e..b2298ed95 100644 --- a/packages/flutter_genui/lib/src/catalog/core_widgets/column.dart +++ b/packages/flutter_genui/lib/src/catalog/core_widgets/column.dart @@ -167,16 +167,19 @@ final column = CatalogItem( { 'id': 'submit_button', 'widget': { - 'ElevatedButton': {'child': 'submit_button_text'}, + 'ElevatedButton': { + 'child': 'submit_button_text', + 'action': {'actionName': 'submit'}, + }, }, }, { + 'id': 'submit_button_text', 'widget': { 'Text': { 'text': {'literalString': 'Submit'}, }, }, - 'id': 'submit_button_text', }, ], }, diff --git a/packages/flutter_genui/lib/src/catalog/core_widgets/elevated_button.dart b/packages/flutter_genui/lib/src/catalog/core_widgets/elevated_button.dart index f7a9f8429..8b46d39c8 100644 --- a/packages/flutter_genui/lib/src/catalog/core_widgets/elevated_button.dart +++ b/packages/flutter_genui/lib/src/catalog/core_widgets/elevated_button.dart @@ -7,6 +7,8 @@ import 'package:flutter/material.dart'; import 'package:json_schema_builder/json_schema_builder.dart'; +import '../../core/widget_utilities.dart'; +import '../../model/a2ui_schemas.dart'; import '../../model/catalog_item.dart'; import '../../model/ui_models.dart'; import '../../primitives/simple_items.dart'; @@ -18,21 +20,21 @@ final _schema = S.object( 'The ID of a child widget. This should always be set, e.g. to the ID ' 'of a `Text` widget.', ), - 'action': S.string( - description: - 'A short description of what should happen when the button is ' - 'pressed to be used by the LLM.', + 'action': A2uiSchemas.action( + description: 'The action to perform when the button is pressed.', ), }, - required: ['child'], + required: ['child', 'action'], ); extension type _ElevatedButtonData.fromMap(JsonMap _json) { - factory _ElevatedButtonData({required String child, String? action}) => - _ElevatedButtonData.fromMap({'child': child, 'action': action}); + factory _ElevatedButtonData({ + required String child, + required JsonMap action, + }) => _ElevatedButtonData.fromMap({'child': child, 'action': action}); String get child => _json['child'] as String; - String? get action => _json['action'] as String?; + JsonMap get action => _json['action'] as JsonMap; } final elevatedButton = CatalogItem( @@ -49,11 +51,40 @@ final elevatedButton = CatalogItem( }) { final buttonData = _ElevatedButtonData.fromMap(data as JsonMap); final child = buildChild(buttonData.child); + final actionData = buttonData.action; + final actionName = actionData['actionName'] as String; + final contextDefinition = + (actionData['context'] as List?) ?? []; + return ElevatedButton( - onPressed: () => dispatchEvent( - UiActionEvent(widgetId: id, eventType: 'onTap', value: {}), - ), + onPressed: () { + final resolvedContext = resolveContext( + dataContext, + contextDefinition, + ); + dispatchEvent( + UserActionEvent( + actionName: actionName, + sourceComponentId: id, + context: resolvedContext, + ), + ); + }, child: child, ); }, + exampleData: [ + () => { + 'root': 'button', + 'widgets': [ + { + 'id': 'button', + 'type': 'ElevatedButton', + 'child': 'text', + 'action': {'actionName': 'button_pressed'}, + }, + {'id': 'text', 'type': 'Text', 'text': 'Hello World'}, + ], + }, + ], ); diff --git a/packages/flutter_genui/lib/src/catalog/core_widgets/image.dart b/packages/flutter_genui/lib/src/catalog/core_widgets/image.dart index fcb5aece1..3bea3da38 100644 --- a/packages/flutter_genui/lib/src/catalog/core_widgets/image.dart +++ b/packages/flutter_genui/lib/src/catalog/core_widgets/image.dart @@ -36,6 +36,24 @@ extension type _ImageData.fromMap(JsonMap _json) { final image = CatalogItem( name: 'Image', dataSchema: _schema, + exampleData: [ + () => { + 'root': 'image', + 'widgets': [ + { + 'id': 'image', + 'widget': { + 'Image': { + 'location': { + 'literalString': + 'https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png', + }, + }, + }, + }, + ], + }, + ], widgetBuilder: ({ required data, diff --git a/packages/flutter_genui/lib/src/catalog/core_widgets/text.dart b/packages/flutter_genui/lib/src/catalog/core_widgets/text.dart index 51b20bb56..5cfc90545 100644 --- a/packages/flutter_genui/lib/src/catalog/core_widgets/text.dart +++ b/packages/flutter_genui/lib/src/catalog/core_widgets/text.dart @@ -27,6 +27,21 @@ final text = CatalogItem( }, required: ['text'], ), + exampleData: [ + () => { + 'root': 'text', + 'widgets': [ + { + 'id': 'text', + 'widget': { + 'Text': { + 'text': {'literalString': 'Hello World'}, + }, + }, + }, + ], + }, + ], widgetBuilder: ({ required data, diff --git a/packages/flutter_genui/lib/src/catalog/core_widgets/text_field.dart b/packages/flutter_genui/lib/src/catalog/core_widgets/text_field.dart index 9d49b2598..78df5f3c8 100644 --- a/packages/flutter_genui/lib/src/catalog/core_widgets/text_field.dart +++ b/packages/flutter_genui/lib/src/catalog/core_widgets/text_field.dart @@ -20,6 +20,9 @@ final _schema = S.object( 'obscureText': S.boolean( description: 'Whether the text should be obscured.', ), + 'onSubmittedAction': A2uiSchemas.action( + description: 'The action to perform when the text field is submitted.', + ), }, ); @@ -28,15 +31,18 @@ extension type _TextFieldData.fromMap(JsonMap _json) { required JsonMap value, String? hintText, bool? obscureText, + JsonMap? onSubmittedAction, }) => _TextFieldData.fromMap({ 'value': value, 'hintText': hintText, 'obscureText': obscureText, + 'onSubmittedAction': onSubmittedAction, }); JsonMap get value => _json['value'] as JsonMap; String? get hintText => _json['hintText'] as String?; bool get obscureText => (_json['obscureText'] as bool?) ?? false; + JsonMap? get onSubmittedAction => _json['onSubmittedAction'] as JsonMap?; } class _TextField extends StatefulWidget { @@ -96,6 +102,21 @@ class _TextFieldState extends State<_TextField> { final textField = CatalogItem( name: 'TextField', dataSchema: _schema, + exampleData: [ + () => { + 'root': 'text_field', + 'widgets': [ + { + 'id': 'text_field', + 'widget': { + 'TextField': { + 'value': {'literalString': 'Hello World'}, + }, + }, + }, + ], + }, + ], widgetBuilder: ({ required data, @@ -123,11 +144,22 @@ final textField = CatalogItem( } }, onSubmitted: (newValue) { + final actionData = textFieldData.onSubmittedAction; + if (actionData == null) { + return; + } + final actionName = actionData['actionName'] as String; + final contextDefinition = + (actionData['context'] as List?) ?? []; + final resolvedContext = resolveContext( + dataContext, + contextDefinition, + ); dispatchEvent( - UiActionEvent( - widgetId: id, - eventType: 'onSubmitted', - value: newValue, + UserActionEvent( + actionName: actionName, + sourceComponentId: id, + context: resolvedContext, ), ); }, diff --git a/packages/flutter_genui/lib/src/core/genui_manager.dart b/packages/flutter_genui/lib/src/core/genui_manager.dart index 69435298c..c6b6a635d 100644 --- a/packages/flutter_genui/lib/src/core/genui_manager.dart +++ b/packages/flutter_genui/lib/src/core/genui_manager.dart @@ -123,12 +123,22 @@ class GenUiManager implements GenUiHost { @override void handleUiEvent(UiEvent event) { - if (event is! UiActionEvent) throw ArgumentError('Unexpected event type'); - final currentState = dataModels[event.surfaceId]?.data ?? const {}; - final eventString = - 'Action: ${jsonEncode(event.value)}\n' - 'Current state: ${jsonEncode(currentState)}'; - _onSubmit.add(UserMessage([TextPart(eventString)])); + if (event is! UserActionEvent) { + // Or handle other event types if necessary + return; + } + + final userActionPayload = { + 'userAction': { + 'actionName': event.actionName, + 'sourceComponentId': event.sourceComponentId, + 'timestamp': event.timestamp.toIso8601String(), + 'context': event.context, + }, + }; + + final eventJsonString = jsonEncode(userActionPayload); + _onSubmit.add(UserMessage.text(eventJsonString)); } @override diff --git a/packages/flutter_genui/lib/src/core/genui_surface.dart b/packages/flutter_genui/lib/src/core/genui_surface.dart index d843d0f97..b97d57b58 100644 --- a/packages/flutter_genui/lib/src/core/genui_surface.dart +++ b/packages/flutter_genui/lib/src/core/genui_surface.dart @@ -94,8 +94,10 @@ class _GenUiSurfaceState extends State { void _dispatchEvent(UiEvent event) { // The event comes in without a surfaceId, which we add here. - widget.host.handleUiEvent( - UiEvent.fromMap({...event.toMap(), 'surfaceId': widget.surfaceId}), - ); + final eventMap = {...event.toMap(), 'surfaceId': widget.surfaceId}; + final newEvent = event is UserActionEvent + ? UserActionEvent.fromMap(eventMap) + : UiEvent.fromMap(eventMap); + widget.host.handleUiEvent(newEvent); } } diff --git a/packages/flutter_genui/lib/src/core/widget_utilities.dart b/packages/flutter_genui/lib/src/core/widget_utilities.dart index 438116f1e..0cbfdb059 100644 --- a/packages/flutter_genui/lib/src/core/widget_utilities.dart +++ b/packages/flutter_genui/lib/src/core/widget_utilities.dart @@ -68,3 +68,17 @@ extension DataContextExtensions on DataContext { return _subscribeToValue>(ref, 'literalStringArray'); } } + +/// Resolves a context map definition against a [DataContext]. +JsonMap resolveContext( + DataContext dataContext, + List contextDefinitions, +) { + final resolved = {}; + for (final contextEntry in contextDefinitions) { + final entry = contextEntry as JsonMap; + final key = entry['key']! as String; + resolved[key] = dataContext.getValue(entry['path'] as String); + } + return resolved; +} diff --git a/packages/flutter_genui/lib/src/development_utilities/catalog_view.dart b/packages/flutter_genui/lib/src/development_utilities/catalog_view.dart index 0b8c99cbe..be722a6d2 100644 --- a/packages/flutter_genui/lib/src/development_utilities/catalog_view.dart +++ b/packages/flutter_genui/lib/src/development_utilities/catalog_view.dart @@ -59,13 +59,20 @@ class _DebugCatalogViewState extends State { final surfaceId = item.key; final definition = item.value(); final widgets = definition['widgets'] as List; - final components = widgets.map((e) { - final widget = e as JsonMap; - return Component( - id: widget['id'] as String, - componentProperties: widget['widget'] as JsonMap, - ); - }).toList(); + final components = widgets + .map((e) { + final widget = e as JsonMap; + final widgetMap = widget['widget'] as JsonMap?; + if (widgetMap == null) { + return null; + } + return Component( + id: widget['id'] as String, + componentProperties: widgetMap.values.first as JsonMap, + ); + }) + .whereType() + .toList(); _genUi.handleMessage( SurfaceUpdate(surfaceId: surfaceId, components: components), ); diff --git a/packages/flutter_genui/lib/src/model/a2ui_schemas.dart b/packages/flutter_genui/lib/src/model/a2ui_schemas.dart index 82da9f9bc..812bcfa66 100644 --- a/packages/flutter_genui/lib/src/model/a2ui_schemas.dart +++ b/packages/flutter_genui/lib/src/model/a2ui_schemas.dart @@ -72,25 +72,21 @@ class A2uiSchemas { static Schema action({String? description}) => S.object( description: description, properties: { - 'action': S.string(), + 'actionName': S.string( + description: 'The name of the action to be sent to the server.', + ), 'context': S.list( + description: + 'A list of name-value pairs to be sent with the action. The ' + 'values are bind to the data model with a path, and should bind ' + 'to all of the related data for this action.', items: S.object( - properties: { - 'key': S.string(), - 'value': S.object( - properties: { - 'path': S.string(), - 'literalString': S.string(), - 'literalNumber': S.number(), - 'literalBoolean': S.boolean(), - }, - ), - }, - required: ['key', 'value'], + properties: {'key': S.string(), 'path': S.string()}, + required: ['key', 'path'], ), ), }, - required: ['action'], + required: ['actionName'], ); /// Schema for a value that can be either a literal array of strings or a diff --git a/packages/flutter_genui/lib/src/model/ui_models.dart b/packages/flutter_genui/lib/src/model/ui_models.dart index 54fbeaa0c..36fb37fb6 100644 --- a/packages/flutter_genui/lib/src/model/ui_models.dart +++ b/packages/flutter_genui/lib/src/model/ui_models.dart @@ -50,22 +50,26 @@ extension type UiEvent.fromMap(JsonMap _json) { /// /// This is used for events that should trigger a submission to the AI, such as /// tapping a button. -extension type UiActionEvent.fromMap(JsonMap _json) implements UiEvent { - /// Creates a [UiEvent] from a set of properties. - UiActionEvent({ +extension type UserActionEvent.fromMap(JsonMap _json) implements UiEvent { + /// Creates a [UserActionEvent] from a set of properties. + UserActionEvent({ String? surfaceId, - required String widgetId, - required String eventType, + required String actionName, + required String sourceComponentId, DateTime? timestamp, - Object? value, + JsonMap? context, }) : _json = { if (surfaceId != null) 'surfaceId': surfaceId, - 'widgetId': widgetId, - 'eventType': eventType, + 'actionName': actionName, + 'sourceComponentId': sourceComponentId, 'timestamp': (timestamp ?? DateTime.now()).toIso8601String(), 'isAction': true, - if (value != null) 'value': value, + 'context': context ?? {}, }; + + String get actionName => _json['actionName'] as String; + String get sourceComponentId => _json['sourceComponentId'] as String; + JsonMap get context => _json['context'] as JsonMap; } /// A data object that represents the entire UI definition. diff --git a/packages/flutter_genui/test/catalog/core_widgets_test.dart b/packages/flutter_genui/test/catalog/core_widgets_test.dart index fa9401cd2..8b44f8481 100644 --- a/packages/flutter_genui/test/catalog/core_widgets_test.dart +++ b/packages/flutter_genui/test/catalog/core_widgets_test.dart @@ -48,7 +48,10 @@ void main() { const Component( id: 'button', componentProperties: { - 'ElevatedButton': {'child': 'text'}, + 'ElevatedButton': { + 'child': 'text', + 'action': {'actionName': 'testAction'}, + }, }, ), const Component( @@ -212,6 +215,7 @@ void main() { 'TextField': { 'value': {'path': '/myValue'}, 'hintText': 'hint', + 'onSubmittedAction': {'actionName': 'submit'}, }, }, ), diff --git a/packages/flutter_genui/test/core/genui_manager_test.dart b/packages/flutter_genui/test/core/genui_manager_test.dart index 7e61e6560..fc68a5117 100644 --- a/packages/flutter_genui/test/core/genui_manager_test.dart +++ b/packages/flutter_genui/test/core/genui_manager_test.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:convert'; + import 'package:flutter_genui/flutter_genui.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -144,5 +146,32 @@ void main() { await Future.delayed(Duration.zero); expect(isClosed, isTrue); }); + + test('can handle UI event', () async { + manager + .dataModelForSurface('testSurface') + .update('/myValue', 'testValue'); + final future = manager.onSubmit.first; + final now = DateTime.now(); + final event = UserActionEvent( + surfaceId: 'testSurface', + actionName: 'testAction', + sourceComponentId: 'testWidget', + timestamp: now, + context: {'key': 'value'}, + ); + manager.handleUiEvent(event); + final message = await future; + expect(message, isA()); + final expectedJson = jsonEncode({ + 'userAction': { + 'actionName': 'testAction', + 'sourceComponentId': 'testWidget', + 'timestamp': now.toIso8601String(), + 'context': {'key': 'value'}, + }, + }); + expect(message.text, expectedJson); + }); }); } diff --git a/packages/flutter_genui/test/genui_surface_test.dart b/packages/flutter_genui/test/genui_surface_test.dart index 17845c092..2e00ea940 100644 --- a/packages/flutter_genui/test/genui_surface_test.dart +++ b/packages/flutter_genui/test/genui_surface_test.dart @@ -24,7 +24,10 @@ void main() { const Component( id: 'root', componentProperties: { - 'ElevatedButton': {'child': 'text'}, + 'ElevatedButton': { + 'child': 'text', + 'action': {'actionName': 'testAction'}, + }, }, ), const Component( @@ -63,7 +66,10 @@ void main() { const Component( id: 'root', componentProperties: { - 'ElevatedButton': {'child': 'text'}, + 'ElevatedButton': { + 'child': 'text', + 'action': {'actionName': 'testAction'}, + }, }, ), const Component( diff --git a/packages/flutter_genui/test/model/ui_models_test.dart b/packages/flutter_genui/test/model/ui_models_test.dart index c31b883ea..02eb3d02a 100644 --- a/packages/flutter_genui/test/model/ui_models_test.dart +++ b/packages/flutter_genui/test/model/ui_models_test.dart @@ -6,62 +6,62 @@ import 'package:flutter_genui/src/model/ui_models.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - group('UiEvent', () { + group('UserActionEvent', () { test('can be created and read', () { final now = DateTime.now(); - final event = UiActionEvent( + final event = UserActionEvent( surfaceId: 'testSurface', - widgetId: 'testWidget', - eventType: 'onTap', + actionName: 'testAction', + sourceComponentId: 'testWidget', timestamp: now, - value: 'testValue', + context: {'key': 'value'}, ); expect(event.surfaceId, 'testSurface'); - expect(event.widgetId, 'testWidget'); - expect(event.eventType, 'onTap'); - expect(event.isAction, isTrue); + expect(event.actionName, 'testAction'); + expect(event.sourceComponentId, 'testWidget'); expect(event.timestamp, now); - expect(event.value, 'testValue'); + expect(event.isAction, isTrue); + expect(event.context, {'key': 'value'}); }); test('can be created from map and read', () { final now = DateTime.now(); - final event = UiEvent.fromMap({ + final event = UserActionEvent.fromMap({ 'surfaceId': 'testSurface', - 'widgetId': 'testWidget', - 'eventType': 'onTap', - 'isAction': false, + 'actionName': 'testAction', + 'sourceComponentId': 'testWidget', 'timestamp': now.toIso8601String(), - 'value': 'testValue', + 'isAction': true, + 'context': {'key': 'value'}, }); expect(event.surfaceId, 'testSurface'); - expect(event.widgetId, 'testWidget'); - expect(event.eventType, 'onTap'); - expect(event.isAction, isFalse); + expect(event.actionName, 'testAction'); + expect(event.sourceComponentId, 'testWidget'); expect(event.timestamp, now); - expect(event.value, 'testValue'); + expect(event.isAction, isTrue); + expect(event.context, {'key': 'value'}); }); test('can be converted to map', () { final now = DateTime.now(); - final event = UiActionEvent( + final event = UserActionEvent( surfaceId: 'testSurface', - widgetId: 'testWidget', - eventType: 'onTap', + actionName: 'testAction', + sourceComponentId: 'testWidget', timestamp: now, - value: 'testValue', + context: {'key': 'value'}, ); final map = event.toMap(); expect(map['surfaceId'], 'testSurface'); - expect(map['widgetId'], 'testWidget'); - expect(map['eventType'], 'onTap'); - expect(map['isAction'], isTrue); + expect(map['actionName'], 'testAction'); + expect(map['sourceComponentId'], 'testWidget'); expect(map['timestamp'], now.toIso8601String()); - expect(map['value'], 'testValue'); + expect(map['isAction'], isTrue); + expect(map['context'], {'key': 'value'}); }); }); }