Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 17 additions & 8 deletions examples/travel_app/lib/src/catalog/input_group.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Object?> _json) {
factory _InputGroupData({
required JsonMap submitLabel,
required List<String> children,
required JsonMap action,
}) => _InputGroupData.fromMap({
'submitLabel': submitLabel,
'children': children,
'action': action,
});

JsonMap get submitLabel => _json['submitLabel'] as JsonMap;
List<String> get children => (_json['children'] as List).cast<String>();
JsonMap get action => _json['action'] as JsonMap;
}

/// A container widget that visually groups a collection of input chips.
Expand Down Expand Up @@ -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,
Expand Down
55 changes: 55 additions & 0 deletions examples/travel_app/test/input_group_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ void main() {
final data = {
'submitLabel': {'literalString': 'Submit'},
'children': ['child1', 'child2'],
'action': {'action': 'submit_action'},
};
UiEvent? dispatchedEvent;

Expand Down Expand Up @@ -52,6 +53,9 @@ void main() {
expect(dispatchedEvent, isA<UiActionEvent>());
expect(dispatchedEvent?.eventType, 'submit');
expect((dispatchedEvent as UiActionEvent).widgetId, 'testId');
expect((dispatchedEvent as UiActionEvent).value, {
'action': 'submit_action',
});
},
);

Expand All @@ -61,6 +65,7 @@ void main() {
final data = {
'submitLabel': {'literalString': 'Submit'},
'children': <String>[],
'action': {'action': 'submit_action'},
};

await tester.pumpWidget(
Expand All @@ -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': <String>[],
'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<UiActionEvent>());
final actionEvent = dispatchedEvent as UiActionEvent;
final value = actionEvent.value as Map<String, Object?>;
expect(value['action'], 'submit_action');
});
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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(
Expand All @@ -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,
);
},
Expand Down
64 changes: 59 additions & 5 deletions packages/flutter_genui/lib/src/core/genui_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Object?> 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<String, Object?> _resolveActionContext(
JsonMap action,
DataModel dataModel,
) {
final resolvedContext = <String, Object?>{};
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
Expand Down
3 changes: 3 additions & 0 deletions packages/flutter_genui/lib/src/model/gulf_schemas.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
61 changes: 60 additions & 1 deletion packages/flutter_genui/test/catalog/core_widgets_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -43,7 +45,10 @@ void main() {
{
'id': 'button',
'widget': {
'ElevatedButton': {'child': 'text'},
'ElevatedButton': {
'child': 'text',
'action': {'action': 'test_action'},
},
},
},
{
Expand All @@ -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<String, Object?>;
expect(eventMap['actionName'], 'test_action');
expect(eventMap['timestamp'], isNotNull);
expect(eventMap['resolvedContext'], <String, Object?>{});
});

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<String, Object?>;
expect(eventMap['resolvedContext'], {
'userName': 'Alice',
'source': 'button',
});
});

testWidgets('Text renders from data model', (WidgetTester tester) async {
Expand Down
Loading