diff --git a/examples/travel_app/lib/src/catalog/input_group.dart b/examples/travel_app/lib/src/catalog/input_group.dart index 0537e1a17..949ec1d5d 100644 --- a/examples/travel_app/lib/src/catalog/input_group.dart +++ b/examples/travel_app/lib/src/catalog/input_group.dart @@ -17,21 +17,27 @@ final _schema = S.object( 'be input types such as OptionsFilterChipInput.', items: S.string(), ), + 'action': GulfSchemas.action( + description: 'The action to perform when the submit button is pressed.', + ), }, - 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. @@ -128,13 +134,16 @@ final inputGroup = CatalogItem( valueListenable: notifier, builder: (context, submitLabel, child) { return ElevatedButton( - onPressed: () => dispatchEvent( - UiActionEvent( - widgetId: id, - eventType: 'submit', - value: {}, - ), - ), + onPressed: () { + final action = inputGroupData.action; + dispatchEvent( + UiActionEvent( + widgetId: id, + eventType: 'submit', + value: action, + ), + ); + }, child: Text(submitLabel ?? ''), style: ElevatedButton.styleFrom( backgroundColor: Theme.of(context).colorScheme.primary, diff --git a/examples/travel_app/test/input_group_test.dart b/examples/travel_app/test/input_group_test.dart index 57283bcfa..03d19b3bf 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': {'action': 'submit_action'}, }; UiEvent? dispatchedEvent; @@ -52,6 +53,9 @@ void main() { expect(dispatchedEvent, isA()); expect(dispatchedEvent?.eventType, 'submit'); expect((dispatchedEvent as UiActionEvent).widgetId, 'testId'); + expect((dispatchedEvent as UiActionEvent).value, { + 'action': 'submit_action', + }); }, ); @@ -61,6 +65,7 @@ void main() { final data = { 'submitLabel': {'literalString': 'Submit'}, 'children': [], + 'action': {'action': 'submit_action'}, }; await tester.pumpWidget( @@ -86,5 +91,55 @@ void main() { expect(find.byType(Text), findsOneWidget); // The button label expect(find.widgetWithText(ElevatedButton, 'Submit'), findsOneWidget); }); + + testWidgets('resolves context on submit', (WidgetTester tester) async { + final data = { + 'submitLabel': {'literalString': 'Submit'}, + 'children': [], + 'action': { + 'action': 'submit_action', + 'context': [ + { + 'key': 'userName', + 'value': {'path': '/name'}, + }, + { + 'key': 'source', + 'value': {'literalString': 'inputGroup'}, + }, + ], + }, + }; + UiEvent? dispatchedEvent; + final dataModel = DataModel(); + dataModel.update('/name', 'Alice'); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) { + return inputGroup.widgetBuilder( + data: data, + id: 'testId', + buildChild: (_) => const SizedBox.shrink(), + dispatchEvent: (event) { + dispatchedEvent = event; + }, + context: context, + dataContext: DataContext(dataModel, '/'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.byType(ElevatedButton)); + expect(dispatchedEvent, isA()); + final actionEvent = dispatchedEvent as UiActionEvent; + final value = actionEvent.value as Map; + expect(value['action'], 'submit_action'); + }); }); } 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..a1526c3fa 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 @@ -8,6 +8,7 @@ import 'package:flutter/material.dart'; import 'package:json_schema_builder/json_schema_builder.dart'; import '../../model/catalog_item.dart'; +import '../../model/gulf_schemas.dart'; import '../../model/ui_models.dart'; import '../../primitives/simple_items.dart'; @@ -18,21 +19,23 @@ 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( + 'action': GulfSchemas.action( description: 'A short description of what should happen when the button is ' 'pressed to be used by the LLM.', ), }, - 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( @@ -50,9 +53,15 @@ final elevatedButton = CatalogItem( final buttonData = _ElevatedButtonData.fromMap(data as JsonMap); final child = buildChild(buttonData.child); return ElevatedButton( - onPressed: () => dispatchEvent( - UiActionEvent(widgetId: id, eventType: 'onTap', value: {}), - ), + onPressed: () { + dispatchEvent( + UiActionEvent( + widgetId: id, + eventType: 'onTap', + value: buttonData.action, + ), + ); + }, child: child, ); }, diff --git a/packages/flutter_genui/lib/src/core/genui_manager.dart b/packages/flutter_genui/lib/src/core/genui_manager.dart index 8d8390500..ecfee962d 100644 --- a/packages/flutter_genui/lib/src/core/genui_manager.dart +++ b/packages/flutter_genui/lib/src/core/genui_manager.dart @@ -124,11 +124,65 @@ 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)])); + final surfaceDataModel = dataModels[event.surfaceId]; + final currentState = surfaceDataModel?.data ?? const {}; + + final value = event.value; + final String actionName; + final Map resolvedContext; + + if (surfaceDataModel != null && + value is JsonMap && + value.containsKey('action')) { + actionName = value['action'] as String; + resolvedContext = _resolveActionContext(value, surfaceDataModel); + } else { + actionName = event.eventType; + resolvedContext = currentState; + } + + final eventMap = { + 'actionName': actionName, + 'sourceComponentId': event.widgetId, + 'timestamp': DateTime.now().toIso8601String(), + 'resolvedContext': resolvedContext, + }; + + _onSubmit.add(UserMessage([TextPart(jsonEncode(eventMap))])); + } + + Map _resolveActionContext( + JsonMap action, + DataModel dataModel, + ) { + final resolvedContext = {}; + final context = action['context'] as List?; + if (context == null) { + return resolvedContext; + } + + for (final item in context) { + if (item is! Map) continue; + final key = item['key'] as String?; + final value = item['value'] as Map?; + if (key == null || value == null) continue; + + if (value.containsKey('path')) { + resolvedContext[key] = dataModel.getValue(value['path'] as String); + } else if (value.containsKey('literalString')) { + resolvedContext[key] = value['literalString']; + } else if (value.containsKey('literalNumber')) { + resolvedContext[key] = value['literalNumber']; + } else if (value.containsKey('literalBoolean')) { + resolvedContext[key] = value['literalBoolean']; + } else { + throw ArgumentError( + 'Invalid action context value: a literal key or a path to bound ' + 'value is required. Received: ${jsonEncode(value)}', + ); + } + } + return resolvedContext; } @override diff --git a/packages/flutter_genui/lib/src/model/gulf_schemas.dart b/packages/flutter_genui/lib/src/model/gulf_schemas.dart index dd06d8607..07c06037a 100644 --- a/packages/flutter_genui/lib/src/model/gulf_schemas.dart +++ b/packages/flutter_genui/lib/src/model/gulf_schemas.dart @@ -78,6 +78,9 @@ class GulfSchemas { properties: { 'key': S.string(), 'value': S.object( + description: + 'The dynamic value. Define EXACTLY ONE of the nested ' + 'properties.', properties: { 'path': S.string(), 'literalString': S.string(), diff --git a/packages/flutter_genui/test/catalog/core_widgets_test.dart b/packages/flutter_genui/test/catalog/core_widgets_test.dart index ea28aba89..895971527 100644 --- a/packages/flutter_genui/test/catalog/core_widgets_test.dart +++ b/packages/flutter_genui/test/catalog/core_widgets_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/material.dart'; import 'package:flutter_genui/flutter_genui.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -43,7 +45,10 @@ void main() { { 'id': 'button', 'widget': { - 'ElevatedButton': {'child': 'text'}, + 'ElevatedButton': { + 'child': 'text', + 'action': {'action': 'test_action'}, + }, }, }, { @@ -64,6 +69,60 @@ void main() { expect(message, null); await tester.tap(find.byType(ElevatedButton)); expect(message, isNotNull); + final eventMap = jsonDecode(message!.text) as Map; + expect(eventMap['actionName'], 'test_action'); + expect(eventMap['timestamp'], isNotNull); + expect(eventMap['resolvedContext'], {}); + }); + + testWidgets('ElevatedButton resolves context on tap', ( + WidgetTester tester, + ) async { + final definition = { + 'root': 'button', + 'widgets': [ + { + 'id': 'button', + 'widget': { + 'ElevatedButton': { + 'child': 'text', + 'action': { + 'action': 'test_action', + 'context': [ + { + 'key': 'userName', + 'value': {'path': '/name'}, + }, + { + 'key': 'source', + 'value': {'literalString': 'button'}, + }, + ], + }, + }, + }, + }, + { + 'id': 'text', + 'widget': { + 'Text': { + 'text': {'literalString': 'Click Me'}, + }, + }, + }, + ], + }; + + await pumpWidgetWithDefinition(tester, definition); + manager!.dataModelForSurface('testSurface').update('/name', 'Alice'); + + await tester.tap(find.byType(ElevatedButton)); + expect(message, isNotNull); + final eventMap = jsonDecode(message!.text) as Map; + expect(eventMap['resolvedContext'], { + 'userName': 'Alice', + 'source': 'button', + }); }); testWidgets('Text renders from data model', (WidgetTester tester) async { diff --git a/packages/flutter_genui/test/core/genui_manager_test.dart b/packages/flutter_genui/test/core/genui_manager_test.dart index 2532ef8fb..b66fcd805 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'; @@ -142,5 +144,104 @@ void main() { await Future.delayed(Duration.zero); expect(isClosed, isTrue); }); + + test( + 'handleUiEvent throws ArgumentError when given a context object with an' + 'unexpected key', + () { + manager.dataModelForSurface('s1'); + + final event = UiActionEvent( + surfaceId: 's1', + widgetId: 'w1', + eventType: 'submit', + value: { + 'action': 'testAction', + 'context': [ + { + 'key': 'invalidKey', + 'value': {'invalidValue': 'someValue'}, + }, + ], + }, + ); + + expect( + () => manager.handleUiEvent(event), + throwsA(isA()), + ); + }, + ); + + test('handleUiEvent handles missing context safely', () async { + manager.dataModelForSurface('s1'); + + final event = UiActionEvent( + surfaceId: 's1', + widgetId: 'w1', + eventType: 'submit', + value: {'action': 'testAction'}, + ); + + final futureMessage = manager.onSubmit.first; + manager.handleUiEvent(event); + final message = await futureMessage; + final eventMap = jsonDecode(message.text) as Map; + + expect(eventMap['resolvedContext'], isEmpty); + }); + + test('handleUiEvent handles empty context safely', () async { + manager.dataModelForSurface('s1'); + + final event = UiActionEvent( + surfaceId: 's1', + widgetId: 'w1', + eventType: 'submit', + value: {'action': 'testAction', 'context': []}, + ); + + final futureMessage = manager.onSubmit.first; + manager.handleUiEvent(event); + final message = await futureMessage; + final eventMap = jsonDecode(message.text) as Map; + + expect(eventMap['resolvedContext'], isEmpty); + }); + + test('handleUiEvent resolves context correctly', () async { + // Ensure the data model is created for the surface and populated. + final dataModel = manager.dataModelForSurface('s1'); + dataModel.update('/name', 'Alice'); + + final event = UiActionEvent( + surfaceId: 's1', + widgetId: 'w1', + eventType: 'submit', + value: { + 'action': 'testAction', + 'context': [ + { + 'key': 'userName', + 'value': {'path': '/name'}, + }, + { + 'key': 'source', + 'value': {'literalString': 'test'}, + }, + ], + }, + ); + + final futureMessage = manager.onSubmit.first; + manager.handleUiEvent(event); + final message = await futureMessage; + final eventMap = jsonDecode(message.text) as Map; + + expect(eventMap['resolvedContext'], { + 'userName': 'Alice', + 'source': 'test', + }); + }); }); } diff --git a/packages/flutter_genui/test/genui_surface_test.dart b/packages/flutter_genui/test/genui_surface_test.dart index 6048852bc..92aa2b977 100644 --- a/packages/flutter_genui/test/genui_surface_test.dart +++ b/packages/flutter_genui/test/genui_surface_test.dart @@ -25,7 +25,10 @@ void main() { { 'id': 'root', 'widget': { - 'ElevatedButton': {'child': 'text'}, + 'ElevatedButton': { + 'child': 'text', + 'action': {'action': 'test_action'}, + }, }, }, { @@ -61,7 +64,10 @@ void main() { { 'id': 'root', 'widget': { - 'ElevatedButton': {'child': 'text'}, + 'ElevatedButton': { + 'child': 'text', + 'action': {'action': 'test_action'}, + }, }, }, {