From 5f389009300792a2bb1cb15d7bf5f9d00537f8d2 Mon Sep 17 00:00:00 2001 From: Jacob Simionato Date: Thu, 13 Nov 2025 08:52:12 +1030 Subject: [PATCH 1/2] Add SurfaceController --- examples/custom_backend/lib/main.dart | 4 +- examples/simple_chat/lib/main.dart | 14 +- examples/simple_chat/lib/message.dart | 16 +- .../lib/src/widgets/conversation.dart | 9 +- .../test/widgets/conversation_test.dart | 20 --- .../screens/order_confirmation_screen.dart | 11 +- .../features/screens/presentation_screen.dart | 11 +- .../screens/questionnaire_screen.dart | 11 +- .../screens/shopping_cart_screen.dart | 11 +- .../.guides/docs/connect_to_agent_provider.md | 21 +-- packages/genui/.guides/examples/riddles.dart | 10 +- packages/genui/README.md | 3 +- packages/genui/lib/genui.dart | 1 + .../src/conversation/gen_ui_conversation.dart | 89 ++++++---- .../genui/lib/src/core/genui_manager.dart | 168 ++++++------------ .../genui/lib/src/core/genui_surface.dart | 45 +++-- .../lib/src/core/surface_controller.dart | 102 +++++++++++ .../development_utilities/catalog_view.dart | 6 +- .../catalog/core_widgets/button_test.dart | 7 +- .../test/catalog/core_widgets/card_test.dart | 7 +- .../catalog/core_widgets/check_box_test.dart | 15 +- .../catalog/core_widgets/column_test.dart | 14 +- .../core_widgets/date_time_input_test.dart | 10 +- .../catalog/core_widgets/divider_test.dart | 7 +- .../test/catalog/core_widgets/icon_test.dart | 14 +- .../test/catalog/core_widgets/list_test.dart | 7 +- .../test/catalog/core_widgets/modal_test.dart | 7 +- .../core_widgets/multiple_choice_test.dart | 17 +- .../test/catalog/core_widgets/row_test.dart | 14 +- .../catalog/core_widgets/slider_test.dart | 15 +- .../test/catalog/core_widgets/tabs_test.dart | 7 +- .../genui/test/catalog/core_widgets_test.dart | 16 +- .../genui/test/core/genui_manager_test.dart | 148 ++++++--------- .../test/core/surface_controller_test.dart | 53 ++++++ packages/genui/test/genui_surface_test.dart | 14 +- packages/genui/test/ui_tools_test.dart | 51 ++++-- packages/genui_a2ui/example/lib/main.dart | 28 ++- 37 files changed, 559 insertions(+), 444 deletions(-) create mode 100644 packages/genui/lib/src/core/surface_controller.dart create mode 100644 packages/genui/test/core/surface_controller_test.dart diff --git a/examples/custom_backend/lib/main.dart b/examples/custom_backend/lib/main.dart index f68386932..ffbc5e565 100644 --- a/examples/custom_backend/lib/main.dart +++ b/examples/custom_backend/lib/main.dart @@ -155,9 +155,9 @@ class _IntegrationTesterState extends State<_IntegrationTester> { if (surfaceId == null) { return const Text('_surfaceId == null'); } + final SurfaceController controller = _genUi.getSurfaceController(surfaceId); return GenUiSurface( - surfaceId: surfaceId, - host: _genUi, + controller: controller, defaultBuilder: (_) => const Text('Fallback to defaultBuilder'), ); } diff --git a/examples/simple_chat/lib/main.dart b/examples/simple_chat/lib/main.dart index 439cdf65d..32aaae275 100644 --- a/examples/simple_chat/lib/main.dart +++ b/examples/simple_chat/lib/main.dart @@ -116,10 +116,10 @@ class _ChatScreenState extends State { ); } - void _handleSurfaceAdded(SurfaceAdded surface) { + void _handleSurfaceAdded(SurfaceController controller) { if (!mounted) return; setState(() { - _messages.add(MessageController(surfaceId: surface.surfaceId)); + _messages.add(MessageController(surfaceId: controller.surfaceId)); }); _scrollToBottom(); } @@ -150,9 +150,13 @@ class _ChatScreenState extends State { itemCount: _messages.length, itemBuilder: (context, index) { final MessageController message = _messages[index]; - return ListTile( - title: MessageView(message, _genUiConversation.host), - ); + final SurfaceController? controller = + message.surfaceId == null + ? null + : _genUiConversation.getSurfaceController( + message.surfaceId!, + ); + return ListTile(title: MessageView(message, controller)); }, ), ), diff --git a/examples/simple_chat/lib/message.dart b/examples/simple_chat/lib/message.dart index d72bc23e3..50c3f18b9 100644 --- a/examples/simple_chat/lib/message.dart +++ b/examples/simple_chat/lib/message.dart @@ -14,17 +14,21 @@ class MessageController { } class MessageView extends StatelessWidget { - const MessageView(this.controller, this.host, {super.key}); + const MessageView( + this.messageController, + this.surfaceController, { + super.key, + }); - final MessageController controller; - final GenUiHost host; + final MessageController messageController; + final SurfaceController? surfaceController; @override Widget build(BuildContext context) { - final String? surfaceId = controller.surfaceId; + final SurfaceController? controller = surfaceController; - if (surfaceId == null) return Text(controller.text ?? ''); + if (controller == null) return Text(messageController.text ?? ''); - return GenUiSurface(host: host, surfaceId: surfaceId); + return GenUiSurface(controller: controller); } } diff --git a/examples/travel_app/lib/src/widgets/conversation.dart b/examples/travel_app/lib/src/widgets/conversation.dart index 87f76add4..8928132fc 100644 --- a/examples/travel_app/lib/src/widgets/conversation.dart +++ b/examples/travel_app/lib/src/widgets/conversation.dart @@ -69,13 +69,12 @@ class Conversation extends StatelessWidget { alignment: MainAxisAlignment.start, ); case AiUiMessage(): + final SurfaceController controller = manager.getSurfaceController( + message.surfaceId, + ); return Padding( padding: const EdgeInsets.all(16.0), - child: GenUiSurface( - key: message.uiKey, - host: manager, - surfaceId: message.surfaceId, - ), + child: GenUiSurface(key: message.uiKey, controller: controller), ); case InternalMessage(): return InternalMessageWidget(content: message.text); diff --git a/examples/travel_app/test/widgets/conversation_test.dart b/examples/travel_app/test/widgets/conversation_test.dart index 6ca04aca7..135583930 100644 --- a/examples/travel_app/test/widgets/conversation_test.dart +++ b/examples/travel_app/test/widgets/conversation_test.dart @@ -103,25 +103,5 @@ void main() { expect(find.byType(GenUiSurface), findsOneWidget); expect(find.text('UI Content'), findsOneWidget); }); - - testWidgets('uses custom userPromptBuilder', (WidgetTester tester) async { - final messages = [ - UserMessage(const [TextPart('Hello')]), - ]; - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: Conversation( - messages: messages, - manager: manager, - userPromptBuilder: (context, message) => - const Text('Custom User Prompt'), - ), - ), - ), - ); - expect(find.text('Custom User Prompt'), findsOneWidget); - expect(find.text('Hello'), findsNothing); - }); }); } diff --git a/examples/verdure/client/lib/features/screens/order_confirmation_screen.dart b/examples/verdure/client/lib/features/screens/order_confirmation_screen.dart index 7daa5f6d2..f27b8807c 100644 --- a/examples/verdure/client/lib/features/screens/order_confirmation_screen.dart +++ b/examples/verdure/client/lib/features/screens/order_confirmation_screen.dart @@ -26,18 +26,15 @@ class OrderConfirmationScreen extends ConsumerWidget { .watch(aiProvider) .when( data: (aiState) { + final SurfaceController controller = aiState.genUiManager + .getSurfaceController('confirmation'); return ValueListenableBuilder( - valueListenable: aiState.genUiManager.getSurfaceNotifier( - 'confirmation', - ), + valueListenable: controller.uiDefinitionNotifier, builder: (context, definition, child) { if (definition == null) { return const Center(child: CircularProgressIndicator()); } - return GenUiSurface( - host: aiState.genUiManager, - surfaceId: 'confirmation', - ); + return GenUiSurface(controller: controller); }, ); }, diff --git a/examples/verdure/client/lib/features/screens/presentation_screen.dart b/examples/verdure/client/lib/features/screens/presentation_screen.dart index 14d9d0ce5..17c4d0394 100644 --- a/examples/verdure/client/lib/features/screens/presentation_screen.dart +++ b/examples/verdure/client/lib/features/screens/presentation_screen.dart @@ -50,20 +50,17 @@ class _PresentationScreenState extends ConsumerState { .watch(aiProvider) .when( data: (aiState) { + final SurfaceController controller = aiState.genUiManager + .getSurfaceController('options'); return ValueListenableBuilder( - valueListenable: aiState.genUiManager.getSurfaceNotifier( - 'options', - ), + valueListenable: controller.uiDefinitionNotifier, builder: (context, definition, child) { if (definition == null) { return const Center(child: CircularProgressIndicator()); } return SingleChildScrollView( padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), - child: GenUiSurface( - host: aiState.genUiManager, - surfaceId: 'options', - ), + child: GenUiSurface(controller: controller), ); }, ); diff --git a/examples/verdure/client/lib/features/screens/questionnaire_screen.dart b/examples/verdure/client/lib/features/screens/questionnaire_screen.dart index 5ea9c1a02..45609bdc8 100644 --- a/examples/verdure/client/lib/features/screens/questionnaire_screen.dart +++ b/examples/verdure/client/lib/features/screens/questionnaire_screen.dart @@ -53,20 +53,17 @@ class _QuestionnaireScreenState extends ConsumerState { .watch(aiProvider) .when( data: (aiState) { + final SurfaceController controller = aiState.genUiManager + .getSurfaceController('questionnaire'); return ValueListenableBuilder( - valueListenable: aiState.genUiManager.getSurfaceNotifier( - 'questionnaire', - ), + valueListenable: controller.uiDefinitionNotifier, builder: (context, definition, child) { if (definition == null) { return const Center(child: CircularProgressIndicator()); } return SingleChildScrollView( padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), - child: GenUiSurface( - host: aiState.genUiManager, - surfaceId: 'questionnaire', - ), + child: GenUiSurface(controller: controller), ); }, ); diff --git a/examples/verdure/client/lib/features/screens/shopping_cart_screen.dart b/examples/verdure/client/lib/features/screens/shopping_cart_screen.dart index 9c5bfbd1f..9e5cafc0e 100644 --- a/examples/verdure/client/lib/features/screens/shopping_cart_screen.dart +++ b/examples/verdure/client/lib/features/screens/shopping_cart_screen.dart @@ -33,18 +33,15 @@ class ShoppingCartScreen extends ConsumerWidget { .watch(aiProvider) .when( data: (aiState) { + final SurfaceController controller = aiState.genUiManager + .getSurfaceController('cart'); return ValueListenableBuilder( - valueListenable: aiState.genUiManager.getSurfaceNotifier( - 'cart', - ), + valueListenable: controller.uiDefinitionNotifier, builder: (context, definition, child) { if (definition == null) { return const Center(child: CircularProgressIndicator()); } - return GenUiSurface( - host: aiState.genUiManager, - surfaceId: 'cart', - ); + return GenUiSurface(controller: controller); }, ); }, diff --git a/packages/genui/.guides/docs/connect_to_agent_provider.md b/packages/genui/.guides/docs/connect_to_agent_provider.md index 3c96ffde0..d7c674c00 100644 --- a/packages/genui/.guides/docs/connect_to_agent_provider.md +++ b/packages/genui/.guides/docs/connect_to_agent_provider.md @@ -144,16 +144,17 @@ To receive and display generated UI: ), body: Column( children: [ - Expanded( - child: ListView.builder( - itemCount: _surfaceIds.length, - itemBuilder: (context, index) { - // For each surface, create a GenUiSurface to display it. - final id = _surfaceIds[index]; - return GenUiSurface(host: _genUiConversation.host, surfaceId: id); - }, - ), - ), + Expanded( + child: ListView.builder( + itemCount: _surfaceIds.length, + itemBuilder: (context, index) { + // For each surface, create a GenUiSurface to display it. + final id = _surfaceIds[index]; + final controller = _genUiConversation.getSurfaceController(id); + return GenUiSurface(controller: controller); + }, + ), + ), SafeArea( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), diff --git a/packages/genui/.guides/examples/riddles.dart b/packages/genui/.guides/examples/riddles.dart index f3213ad52..9decf8bdb 100644 --- a/packages/genui/.guides/examples/riddles.dart +++ b/packages/genui/.guides/examples/riddles.dart @@ -76,12 +76,12 @@ class _MyHomePageState extends State { conversation = GenUiConversation( contentGenerator: contentGenerator, genUiManager: genUiManager, - onSurfaceAdded: (update) { + onSurfaceAdded: (controller) { setState(() { messages.add( AiUiMessage( - definition: update.definition, - surfaceId: update.surfaceId, + definition: controller.uiDefinitionNotifier.value!, + surfaceId: controller.surfaceId, ), ); }); @@ -123,8 +123,8 @@ class _MyHomePageState extends State { return switch (message) { AiUiMessage() => GenUiSurface( key: message.uiKey, - host: conversation.host, - surfaceId: message.surfaceId, + controller: + conversation.getSurfaceController(message.surfaceId), ), AiTextMessage() => ChatMessageWidget( text: message.text, diff --git a/packages/genui/README.md b/packages/genui/README.md index cecee14d1..e25039c43 100644 --- a/packages/genui/README.md +++ b/packages/genui/README.md @@ -255,7 +255,8 @@ To receive and display generated UI: itemBuilder: (context, index) { // For each surface, create a GenUiSurface to display it. final id = _surfaceIds[index]; - return GenUiSurface(host: _genUiConversation.host, surfaceId: id); + final controller = _genUiConversation.getSurfaceController(id); + return GenUiSurface(controller: controller); }, ), ), diff --git a/packages/genui/lib/genui.dart b/packages/genui/lib/genui.dart index c027eee7e..23fd75dfb 100644 --- a/packages/genui/lib/genui.dart +++ b/packages/genui/lib/genui.dart @@ -16,6 +16,7 @@ export 'src/core/genui_configuration.dart'; export 'src/core/genui_manager.dart'; export 'src/core/genui_surface.dart'; export 'src/core/prompt_fragments.dart'; +export 'src/core/surface_controller.dart'; export 'src/core/ui_tools.dart'; export 'src/core/widget_utilities.dart'; export 'src/core/widgets/chat_primitives.dart'; diff --git a/packages/genui/lib/src/conversation/gen_ui_conversation.dart b/packages/genui/lib/src/conversation/gen_ui_conversation.dart index 50deac99c..2c86047c7 100644 --- a/packages/genui/lib/src/conversation/gen_ui_conversation.dart +++ b/packages/genui/lib/src/conversation/gen_ui_conversation.dart @@ -8,6 +8,7 @@ import 'package:flutter/foundation.dart'; import '../content_generator.dart'; import '../core/genui_manager.dart'; +import '../core/surface_controller.dart'; import '../model/a2ui_message.dart'; import '../model/chat_message.dart'; import '../model/ui_models.dart'; @@ -55,13 +56,13 @@ class GenUiConversation { final GenUiManager genUiManager; /// A callback for when a new surface is added by the AI. - final ValueChanged? onSurfaceAdded; + final ValueChanged? onSurfaceAdded; /// A callback for when a surface is deleted by the AI. - final ValueChanged? onSurfaceDeleted; + final ValueChanged? onSurfaceDeleted; /// A callback for when a surface is updated by the AI. - final ValueChanged? onSurfaceUpdated; + final ValueChanged? onSurfaceUpdated; /// A callback for when a text response is received from the AI. final ValueChanged? onTextResponse; @@ -79,44 +80,63 @@ class GenUiConversation { ValueNotifier>([]); void _handleSurfaceUpdate(GenUiUpdate update) { + final SurfaceController controller = update.controller; switch (update) { case SurfaceAdded(): - _conversation.value = [ - ..._conversation.value, - AiUiMessage( - definition: update.definition, - surfaceId: update.surfaceId, - ), - ]; - onSurfaceAdded?.call(update); - case SurfaceUpdated(): - final newConversation = List.from(_conversation.value); - final int index = newConversation.lastIndexWhere( - (m) => m is AiUiMessage && m.surfaceId == update.surfaceId, - ); - final newMessage = AiUiMessage( - definition: update.definition, - surfaceId: update.surfaceId, - ); - if (index != -1) { - newConversation[index] = newMessage; - } else { - // This can happen if a surface is created and updated in the same - // turn. - newConversation.add(newMessage); + onSurfaceAdded?.call(controller); + // Listen for updates to this specific controller to manage history. + // We don't store a reference to the listener, so it can't be removed + // later. However, since the lifecycle of the controller is managed by + // the GenUiManager and tied to the conversation, this should not + // result in a memory leak in practice as the controller itself will be + // disposed. + controller.uiDefinitionNotifier.addListener(() { + _handleDefinitionUpdate(controller); + }); + final UiDefinition? initialDefinition = + controller.uiDefinitionNotifier.value; + if (initialDefinition != null) { + _conversation.value = [ + ..._conversation.value, + AiUiMessage( + definition: initialDefinition, + surfaceId: controller.surfaceId, + ), + ]; } - _conversation.value = newConversation; - onSurfaceUpdated?.call(update); case SurfaceRemoved(): + onSurfaceDeleted?.call(controller); final newConversation = List.from(_conversation.value); newConversation.removeWhere( - (m) => m is AiUiMessage && m.surfaceId == update.surfaceId, + (m) => m is AiUiMessage && m.surfaceId == controller.surfaceId, ); _conversation.value = newConversation; - onSurfaceDeleted?.call(update); } } + void _handleDefinitionUpdate(SurfaceController controller) { + onSurfaceUpdated?.call(controller); + final UiDefinition? newDefinition = controller.uiDefinitionNotifier.value; + if (newDefinition == null) return; + + final newConversation = List.from(_conversation.value); + final int index = newConversation.lastIndexWhere( + (m) => m is AiUiMessage && m.surfaceId == controller.surfaceId, + ); + final newMessage = AiUiMessage( + definition: newDefinition, + surfaceId: controller.surfaceId, + ); + if (index != -1) { + newConversation[index] = newMessage; + } else { + // This can happen if a surface is created and updated in the same + // turn. + newConversation.add(newMessage); + } + _conversation.value = newConversation; + } + /// Disposes of the resources used by this agent. void dispose() { _a2uiSubscription.cancel(); @@ -128,9 +148,6 @@ class GenUiConversation { genUiManager.dispose(); } - /// The host for the UI surfaces managed by this agent. - GenUiHost get host => genUiManager; - /// A [ValueListenable] that provides the current conversation history. ValueListenable> get conversation => _conversation; @@ -138,9 +155,9 @@ class GenUiConversation { /// processing a request. ValueListenable get isProcessing => contentGenerator.isProcessing; - /// Returns a [ValueNotifier] for the given [surfaceId]. - ValueNotifier surface(String surfaceId) { - return genUiManager.getSurfaceNotifier(surfaceId); + /// Returns the [SurfaceController] for the given [surfaceId]. + SurfaceController getSurfaceController(String surfaceId) { + return genUiManager.getSurfaceController(surfaceId); } /// Sends a user message to the AI to generate a UI response. diff --git a/packages/genui/lib/src/core/genui_manager.dart b/packages/genui/lib/src/core/genui_manager.dart index daabf84b9..ada581e59 100644 --- a/packages/genui/lib/src/core/genui_manager.dart +++ b/packages/genui/lib/src/core/genui_manager.dart @@ -14,43 +14,29 @@ import '../model/data_model.dart'; import '../model/ui_models.dart'; import '../primitives/logging.dart'; import 'genui_configuration.dart'; +import 'surface_controller.dart'; /// A sealed class representing an update to the UI managed by [GenUiManager]. /// -/// This class has three subclasses: [SurfaceAdded], [SurfaceUpdated], and -/// [SurfaceRemoved]. +/// This class has two subclasses: [SurfaceAdded] and [SurfaceRemoved]. sealed class GenUiUpdate { /// Creates a [GenUiUpdate] for the given [surfaceId]. - const GenUiUpdate(this.surfaceId); + const GenUiUpdate(this.controller); - /// The ID of the surface that was updated. - final String surfaceId; + /// The controller for the surface that was updated. + final SurfaceController controller; } /// Fired when a new surface is created. class SurfaceAdded extends GenUiUpdate { - /// Creates a [SurfaceAdded] event for the given [surfaceId] and - /// [definition]. - const SurfaceAdded(super.surfaceId, this.definition); - - /// The definition of the new surface. - final UiDefinition definition; -} - -/// Fired when an existing surface is modified. -class SurfaceUpdated extends GenUiUpdate { - /// Creates a [SurfaceUpdated] event for the given [surfaceId] and - /// [definition]. - const SurfaceUpdated(super.surfaceId, this.definition); - - /// The new definition of the surface. - final UiDefinition definition; + /// Creates a [SurfaceAdded] event for the given [controller]. + const SurfaceAdded(super.controller); } /// Fired when a surface is deleted. class SurfaceRemoved extends GenUiUpdate { - /// Creates a [SurfaceRemoved] event for the given [surfaceId]. - const SurfaceRemoved(super.surfaceId); + /// Creates a [SurfaceRemoved] event for the given [controller]. + const SurfaceRemoved(super.controller); } /// An interface for a class that hosts UI surfaces. @@ -81,10 +67,10 @@ abstract interface class GenUiHost { /// /// This class is the core state manager for the dynamic UI. It maintains a map /// of all active UI "surfaces", where each surface is represented by a -/// `UiDefinition`. It provides the tools (`surfaceUpdate`, `deleteSurface`, +/// `SurfaceController`. It provides the tools (`surfaceUpdate`, `deleteSurface`, /// `beginRendering`) that the AI uses to manipulate the UI. It exposes a stream /// of `GenUiUpdate` events so that the application can react to changes. -class GenUiManager implements GenUiHost { +class GenUiManager { /// Creates a new [GenUiManager]. /// /// The [catalog] defines the set of widgets available to the AI. @@ -95,62 +81,45 @@ class GenUiManager implements GenUiHost { final GenUiConfiguration configuration; - final _surfaces = >{}; + final _surfaceControllers = {}; final _surfaceUpdates = StreamController.broadcast(); final _onSubmit = StreamController.broadcast(); - final _dataModels = {}; - - @override - Map get dataModels => Map.unmodifiable(_dataModels); - - @override - DataModel dataModelForSurface(String surfaceId) { - return _dataModels.putIfAbsent(surfaceId, DataModel.new); - } - - /// A map of all the surfaces managed by this manager, keyed by surface ID. - Map> get surfaces => _surfaces; - - @override + /// A stream of updates for the surfaces managed by this manager. Stream get surfaceUpdates => _surfaceUpdates.stream; /// A stream of user input messages generated from UI interactions. Stream get onSubmit => _onSubmit.stream; - @override - void handleUiEvent(UiEvent event) { - if (event is! UserActionEvent) { - // Or handle other event types if necessary - return; - } - - final String eventJsonString = jsonEncode({'userAction': event.toMap()}); - _onSubmit.add(UserUiInteractionMessage.text(eventJsonString)); - } - - @override + /// The catalog of UI components available to the AI. final Catalog catalog; - @override - ValueNotifier getSurfaceNotifier(String surfaceId) { - if (!_surfaces.containsKey(surfaceId)) { - genUiLogger.fine('Adding new surface $surfaceId'); - } else { - genUiLogger.fine('Fetching surface notifier for $surfaceId'); + /// Returns the [SurfaceController] for the given [surfaceId]. + /// + /// If a controller for the given [surfaceId] does not exist, a new one is + /// created and a [SurfaceAdded] event is fired on the [surfaceUpdates] + /// stream. + SurfaceController getSurfaceController(String surfaceId) { + if (!_surfaceControllers.containsKey(surfaceId)) { + genUiLogger.fine('Creating new surface controller for $surfaceId'); + final newController = SurfaceController( + surfaceId: surfaceId, + catalog: catalog, + onUiEvent: handleUiEvent, + ); + _surfaceControllers[surfaceId] = newController; + _surfaceUpdates.add(SurfaceAdded(newController)); + return newController; } - return _surfaces.putIfAbsent( - surfaceId, - () => ValueNotifier(null), - ); + return _surfaceControllers[surfaceId]!; } /// Disposes of the resources used by this manager. void dispose() { _surfaceUpdates.close(); _onSubmit.close(); - for (final ValueNotifier notifier in _surfaces.values) { - notifier.dispose(); + for (final SurfaceController controller in _surfaceControllers.values) { + controller.dispose(); } } @@ -158,64 +127,41 @@ class GenUiManager implements GenUiHost { void handleMessage(A2uiMessage message) { switch (message) { case SurfaceUpdate(): - // No need for SurfaceAdded here because A2uiMessage will never generate - // those. We decide here if the surface is new or not, and generate a - // SurfaceAdded event if so. - final String surfaceId = message.surfaceId; - final ValueNotifier notifier = getSurfaceNotifier( - surfaceId, - ); - final isNew = notifier.value == null; - UiDefinition uiDefinition = - notifier.value ?? UiDefinition(surfaceId: surfaceId); - final Map newComponents = Map.of( - uiDefinition.components, + final SurfaceController controller = getSurfaceController( + message.surfaceId, ); - for (final Component component in message.components) { - newComponents[component.id] = component; - } - uiDefinition = uiDefinition.copyWith(components: newComponents); - notifier.value = uiDefinition; - if (isNew) { - genUiLogger.info('Adding surface $surfaceId'); - _surfaceUpdates.add(SurfaceAdded(surfaceId, uiDefinition)); - } else { - genUiLogger.info('Updating surface $surfaceId'); - _surfaceUpdates.add(SurfaceUpdated(surfaceId, uiDefinition)); - } + controller.handleMessage(message); case BeginRendering(): - dataModelForSurface(message.surfaceId); - final ValueNotifier notifier = getSurfaceNotifier( + final SurfaceController controller = getSurfaceController( message.surfaceId, ); - final UiDefinition uiDefinition = - notifier.value ?? UiDefinition(surfaceId: message.surfaceId); - final UiDefinition newUiDefinition = uiDefinition.copyWith( - rootComponentId: message.root, - ); - notifier.value = newUiDefinition; - genUiLogger.info('Started rendering ${message.surfaceId}'); - _surfaceUpdates.add(SurfaceUpdated(message.surfaceId, newUiDefinition)); + controller.handleMessage(message); case DataModelUpdate(): - final String path = message.path ?? '/'; - genUiLogger.info( - 'Updating data model for surface ${message.surfaceId} at path ' - '$path with contents:\n' - '${const JsonEncoder.withIndent(' ').convert(message.contents)}', + final SurfaceController controller = getSurfaceController( + message.surfaceId, ); - final DataModel dataModel = dataModelForSurface(message.surfaceId); - dataModel.update(DataPath(path), message.contents); + controller.handleMessage(message); case SurfaceDeletion(): final String surfaceId = message.surfaceId; - if (_surfaces.containsKey(surfaceId)) { + if (_surfaceControllers.containsKey(surfaceId)) { genUiLogger.info('Deleting surface $surfaceId'); - final ValueNotifier? notifier = _surfaces.remove( + final SurfaceController controller = _surfaceControllers.remove( surfaceId, - ); - notifier?.dispose(); - _dataModels.remove(surfaceId); - _surfaceUpdates.add(SurfaceRemoved(surfaceId)); + )!; + _surfaceUpdates.add(SurfaceRemoved(controller)); + controller.dispose(); } } } + + /// A callback to handle an action from a surface. + void handleUiEvent(UiEvent event) { + if (event is! UserActionEvent) { + // Or handle other event types if necessary + return; + } + + final String eventJsonString = jsonEncode({'userAction': event.toMap()}); + _onSubmit.add(UserUiInteractionMessage.text(eventJsonString)); + } } diff --git a/packages/genui/lib/src/core/genui_surface.dart b/packages/genui/lib/src/core/genui_surface.dart index 10984c726..c3601e39f 100644 --- a/packages/genui/lib/src/core/genui_surface.dart +++ b/packages/genui/lib/src/core/genui_surface.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; -import '../core/genui_manager.dart'; +import '../core/surface_controller.dart'; import '../model/catalog_item.dart'; import '../model/data_model.dart'; import '../model/tools.dart'; @@ -17,21 +17,17 @@ typedef UiEventCallback = void Function(UiEvent event); /// A widget that builds a UI dynamically from a JSON-like definition. /// -/// It reports user interactions via the [host]. +/// It reports user interactions via the [controller]. class GenUiSurface extends StatefulWidget { /// Creates a new [GenUiSurface]. const GenUiSurface({ super.key, - required this.host, - required this.surfaceId, + required this.controller, this.defaultBuilder, }); - /// The manager that holds the state of the UI. - final GenUiHost host; - - /// The ID of the surface that this UI belongs to. - final String surfaceId; + /// The controller that holds the state of the UI. + final SurfaceController controller; /// A builder for the widget to display when the surface has no definition. final WidgetBuilder? defaultBuilder; @@ -43,25 +39,29 @@ class GenUiSurface extends StatefulWidget { class _GenUiSurfaceState extends State { @override Widget build(BuildContext context) { - genUiLogger.fine('Outer Building surface ${widget.surfaceId}'); + genUiLogger.fine('Outer Building surface ${widget.controller.surfaceId}'); return ValueListenableBuilder( - valueListenable: widget.host.getSurfaceNotifier(widget.surfaceId), + valueListenable: widget.controller.uiDefinitionNotifier, builder: (context, definition, child) { - genUiLogger.fine('Building surface ${widget.surfaceId}'); + genUiLogger.fine('Building surface ${widget.controller.surfaceId}'); if (definition == null) { - genUiLogger.info('Surface ${widget.surfaceId} has no definition.'); + genUiLogger.info( + 'Surface ${widget.controller.surfaceId} has no definition.', + ); return widget.defaultBuilder?.call(context) ?? const SizedBox.shrink(); } final String? rootId = definition.rootComponentId; if (rootId == null || definition.components.isEmpty) { - genUiLogger.warning('Surface ${widget.surfaceId} has no widgets.'); + genUiLogger.warning( + 'Surface ${widget.controller.surfaceId} has no widgets.', + ); return const SizedBox.shrink(); } return _buildWidget( definition, rootId, - DataContext(widget.host.dataModelForSurface(widget.surfaceId), '/'), + DataContext(widget.controller.dataModel, '/'), ); }, ); @@ -84,7 +84,7 @@ class _GenUiSurfaceState extends State { final JsonMap widgetData = data.componentProperties; genUiLogger.finest('Building widget $widgetId'); - return widget.host.catalog.buildWidget( + return widget.controller.catalog.buildWidget( CatalogItemContext( id: widgetId, data: widgetData, @@ -95,16 +95,15 @@ class _GenUiSurfaceState extends State { dataContext: dataContext, getComponent: (String componentId) => definition.components[componentId], - surfaceId: widget.surfaceId, + surfaceId: widget.controller.surfaceId, ), ); } void _dispatchEvent(UiEvent event) { if (event is UserActionEvent && event.name == 'showModal') { - final UiDefinition? definition = widget.host - .getSurfaceNotifier(widget.surfaceId) - .value; + final UiDefinition? definition = + widget.controller.uiDefinitionNotifier.value; if (definition == null) return; final modalId = event.context['modalId'] as String; final Component? modalComponent = definition.components[modalId]; @@ -117,7 +116,7 @@ class _GenUiSurfaceState extends State { builder: (context) => _buildWidget( definition, contentChildId, - DataContext(widget.host.dataModelForSurface(widget.surfaceId), '/'), + DataContext(widget.controller.dataModel, '/'), ), ); return; @@ -126,11 +125,11 @@ class _GenUiSurfaceState extends State { // The event comes in without a surfaceId, which we add here. final Map eventMap = { ...event.toMap(), - surfaceIdKey: widget.surfaceId, + surfaceIdKey: widget.controller.surfaceId, }; final UiEvent newEvent = event is UserActionEvent ? UserActionEvent.fromMap(eventMap) : UiEvent.fromMap(eventMap); - widget.host.handleUiEvent(newEvent); + widget.controller.onUiEvent(newEvent); } } diff --git a/packages/genui/lib/src/core/surface_controller.dart b/packages/genui/lib/src/core/surface_controller.dart new file mode 100644 index 000000000..6d3c65f6a --- /dev/null +++ b/packages/genui/lib/src/core/surface_controller.dart @@ -0,0 +1,102 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/foundation.dart'; + +import '../model/a2ui_message.dart'; +import '../model/catalog.dart'; +import '../model/data_model.dart'; +import '../model/ui_models.dart'; +import '../primitives/logging.dart'; + +/// A controller that manages the state of a single UI surface. +/// +/// This class is responsible for maintaining the [UiDefinition] and [DataModel] +/// for a surface, and for handling messages and events related to that surface. +class SurfaceController { + /// Creates a new [SurfaceController]. + SurfaceController({ + required this.surfaceId, + required this.catalog, + required this.onUiEvent, + }) : _uiDefinitionNotifier = ValueNotifier(null); + + /// The unique ID of the surface that this controller manages. + final String surfaceId; + + /// The catalog of UI components available to the AI. + final Catalog catalog; + + /// A callback to handle a UI event from the surface. + final ValueChanged onUiEvent; + + final ValueNotifier _uiDefinitionNotifier; + + /// A [ValueListenable] that provides the current [UiDefinition] for the + /// surface. + ValueListenable get uiDefinitionNotifier => + _uiDefinitionNotifier; + + /// The data model for storing the UI state of the surface. + final DataModel dataModel = DataModel(); + + /// Disposes of the resources used by this controller. + void dispose() { + _uiDefinitionNotifier.dispose(); + } + + /// Handles an [A2uiMessage] and updates the UI accordingly. + void handleMessage(A2uiMessage message) { + if (message is SurfaceUpdate && message.surfaceId != surfaceId) { + throw ArgumentError( + 'Mismatched surfaceId in message: ' + 'expected $surfaceId, got ${message.surfaceId}', + ); + } + if (message is BeginRendering && message.surfaceId != surfaceId) { + throw ArgumentError( + 'Mismatched surfaceId in message: ' + 'expected $surfaceId, got ${message.surfaceId}', + ); + } + if (message is DataModelUpdate && message.surfaceId != surfaceId) { + throw ArgumentError( + 'Mismatched surfaceId in message: ' + 'expected $surfaceId, got ${message.surfaceId}', + ); + } + + switch (message) { + case SurfaceUpdate(): + UiDefinition uiDefinition = + _uiDefinitionNotifier.value ?? UiDefinition(surfaceId: surfaceId); + final Map newComponents = Map.of( + uiDefinition.components, + ); + for (final Component component in message.components) { + newComponents[component.id] = component; + } + uiDefinition = uiDefinition.copyWith(components: newComponents); + _uiDefinitionNotifier.value = uiDefinition; + case BeginRendering(): + final UiDefinition uiDefinition = + _uiDefinitionNotifier.value ?? UiDefinition(surfaceId: surfaceId); + final UiDefinition newUiDefinition = uiDefinition.copyWith( + rootComponentId: message.root, + ); + _uiDefinitionNotifier.value = newUiDefinition; + case DataModelUpdate(): + final String path = message.path ?? '/'; + genUiLogger.info( + 'Updating data model for surface $surfaceId at path ' + '$path with contents:\n' + '${message.contents}', + ); + dataModel.update(DataPath(path), message.contents); + case SurfaceDeletion(): + // This is handled by the GenUiManager, which owns the lifecycle of + // the SurfaceControllers. + } + } +} diff --git a/packages/genui/lib/src/development_utilities/catalog_view.dart b/packages/genui/lib/src/development_utilities/catalog_view.dart index 8ad9d9508..96d4b85ab 100644 --- a/packages/genui/lib/src/development_utilities/catalog_view.dart +++ b/packages/genui/lib/src/development_utilities/catalog_view.dart @@ -10,6 +10,7 @@ import 'package:flutter/material.dart'; import '../core/genui_manager.dart'; import '../core/genui_surface.dart'; +import '../core/surface_controller.dart'; import '../model/a2ui_message.dart'; import '../model/catalog.dart'; import '../model/catalog_item.dart'; @@ -108,7 +109,10 @@ class _DebugCatalogViewState extends State { itemCount: surfaceIds.length, itemBuilder: (BuildContext context, int index) { final String surfaceId = surfaceIds[index]; - final surfaceWidget = GenUiSurface(host: _genUi, surfaceId: surfaceId); + final SurfaceController controller = _genUi.getSurfaceController( + surfaceId, + ); + final surfaceWidget = GenUiSurface(controller: controller); return Card( color: Theme.of(context).colorScheme.secondaryContainer, child: Padding( diff --git a/packages/genui/test/catalog/core_widgets/button_test.dart b/packages/genui/test/catalog/core_widgets/button_test.dart index 26cff1ba0..4ed8f9dbb 100644 --- a/packages/genui/test/catalog/core_widgets/button_test.dart +++ b/packages/genui/test/catalog/core_widgets/button_test.dart @@ -17,6 +17,9 @@ void main() { ); manager.onSubmit.listen((event) => message = event); const surfaceId = 'testSurface'; + final SurfaceController controller = manager.getSurfaceController( + surfaceId, + ); final components = [ const Component( id: 'button', @@ -45,9 +48,7 @@ void main() { await tester.pumpWidget( MaterialApp( - home: Scaffold( - body: GenUiSurface(host: manager, surfaceId: surfaceId), - ), + home: Scaffold(body: GenUiSurface(controller: controller)), ), ); diff --git a/packages/genui/test/catalog/core_widgets/card_test.dart b/packages/genui/test/catalog/core_widgets/card_test.dart index 2c3eca5bc..0e9a5745f 100644 --- a/packages/genui/test/catalog/core_widgets/card_test.dart +++ b/packages/genui/test/catalog/core_widgets/card_test.dart @@ -13,6 +13,9 @@ void main() { configuration: const GenUiConfiguration(), ); const surfaceId = 'testSurface'; + final SurfaceController controller = manager.getSurfaceController( + surfaceId, + ); final components = [ const Component( id: 'card', @@ -38,9 +41,7 @@ void main() { await tester.pumpWidget( MaterialApp( - home: Scaffold( - body: GenUiSurface(host: manager, surfaceId: surfaceId), - ), + home: Scaffold(body: GenUiSurface(controller: controller)), ), ); diff --git a/packages/genui/test/catalog/core_widgets/check_box_test.dart b/packages/genui/test/catalog/core_widgets/check_box_test.dart index e891d0bfe..0bd5b2f11 100644 --- a/packages/genui/test/catalog/core_widgets/check_box_test.dart +++ b/packages/genui/test/catalog/core_widgets/check_box_test.dart @@ -15,6 +15,9 @@ void main() { configuration: const GenUiConfiguration(), ); const surfaceId = 'testSurface'; + final SurfaceController controller = manager.getSurfaceController( + surfaceId, + ); final components = [ const Component( id: 'checkbox', @@ -32,13 +35,14 @@ void main() { manager.handleMessage( const BeginRendering(surfaceId: surfaceId, root: 'checkbox'), ); - manager.dataModelForSurface(surfaceId).update(DataPath('/myValue'), true); + manager + .getSurfaceController(surfaceId) + .dataModel + .update(DataPath('/myValue'), true); await tester.pumpWidget( MaterialApp( - home: Scaffold( - body: GenUiSurface(host: manager, surfaceId: surfaceId), - ), + home: Scaffold(body: GenUiSurface(controller: controller)), ), ); @@ -51,7 +55,8 @@ void main() { await tester.tap(find.byType(CheckboxListTile)); expect( manager - .dataModelForSurface(surfaceId) + .getSurfaceController(surfaceId) + .dataModel .getValue(DataPath('/myValue')), isFalse, ); diff --git a/packages/genui/test/catalog/core_widgets/column_test.dart b/packages/genui/test/catalog/core_widgets/column_test.dart index a23e54cd8..b7d9acc5b 100644 --- a/packages/genui/test/catalog/core_widgets/column_test.dart +++ b/packages/genui/test/catalog/core_widgets/column_test.dart @@ -13,6 +13,9 @@ void main() { configuration: const GenUiConfiguration(), ); const surfaceId = 'testSurface'; + final SurfaceController controller = manager.getSurfaceController( + surfaceId, + ); final components = [ const Component( id: 'column', @@ -50,9 +53,7 @@ void main() { await tester.pumpWidget( MaterialApp( - home: Scaffold( - body: GenUiSurface(host: manager, surfaceId: surfaceId), - ), + home: Scaffold(body: GenUiSurface(controller: controller)), ), ); @@ -68,6 +69,9 @@ void main() { configuration: const GenUiConfiguration(), ); const surfaceId = 'testSurface'; + final SurfaceController controller = manager.getSurfaceController( + surfaceId, + ); final components = [ const Component( id: 'column', @@ -115,9 +119,7 @@ void main() { await tester.pumpWidget( MaterialApp( - home: Scaffold( - body: GenUiSurface(host: manager, surfaceId: surfaceId), - ), + home: Scaffold(body: GenUiSurface(controller: controller)), ), ); diff --git a/packages/genui/test/catalog/core_widgets/date_time_input_test.dart b/packages/genui/test/catalog/core_widgets/date_time_input_test.dart index 05e3e1e08..dda101ef6 100644 --- a/packages/genui/test/catalog/core_widgets/date_time_input_test.dart +++ b/packages/genui/test/catalog/core_widgets/date_time_input_test.dart @@ -15,6 +15,9 @@ void main() { configuration: const GenUiConfiguration(), ); const surfaceId = 'testSurface'; + final SurfaceController controller = manager.getSurfaceController( + surfaceId, + ); final components = [ const Component( id: 'datetime', @@ -32,14 +35,13 @@ void main() { const BeginRendering(surfaceId: surfaceId, root: 'datetime'), ); manager - .dataModelForSurface(surfaceId) + .getSurfaceController(surfaceId) + .dataModel .update(DataPath('/myDateTime'), '2025-10-15'); await tester.pumpWidget( MaterialApp( - home: Scaffold( - body: GenUiSurface(host: manager, surfaceId: surfaceId), - ), + home: Scaffold(body: GenUiSurface(controller: controller)), ), ); diff --git a/packages/genui/test/catalog/core_widgets/divider_test.dart b/packages/genui/test/catalog/core_widgets/divider_test.dart index 78ede0e94..851e71dbe 100644 --- a/packages/genui/test/catalog/core_widgets/divider_test.dart +++ b/packages/genui/test/catalog/core_widgets/divider_test.dart @@ -13,6 +13,9 @@ void main() { configuration: const GenUiConfiguration(), ); const surfaceId = 'testSurface'; + final SurfaceController controller = manager.getSurfaceController( + surfaceId, + ); final components = [ const Component( id: 'divider', @@ -28,9 +31,7 @@ void main() { await tester.pumpWidget( MaterialApp( - home: Scaffold( - body: GenUiSurface(host: manager, surfaceId: surfaceId), - ), + home: Scaffold(body: GenUiSurface(controller: controller)), ), ); diff --git a/packages/genui/test/catalog/core_widgets/icon_test.dart b/packages/genui/test/catalog/core_widgets/icon_test.dart index 8e6673619..18afb16bc 100644 --- a/packages/genui/test/catalog/core_widgets/icon_test.dart +++ b/packages/genui/test/catalog/core_widgets/icon_test.dart @@ -15,6 +15,9 @@ void main() { configuration: const GenUiConfiguration(), ); const surfaceId = 'testSurface'; + final SurfaceController controller = manager.getSurfaceController( + surfaceId, + ); final components = [ const Component( id: 'icon', @@ -34,9 +37,7 @@ void main() { await tester.pumpWidget( MaterialApp( - home: Scaffold( - body: GenUiSurface(host: manager, surfaceId: surfaceId), - ), + home: Scaffold(body: GenUiSurface(controller: controller)), ), ); @@ -51,6 +52,9 @@ void main() { configuration: const GenUiConfiguration(), ); const surfaceId = 'testSurface'; + final SurfaceController controller = manager.getSurfaceController( + surfaceId, + ); final components = [ const Component( id: 'icon', @@ -77,9 +81,7 @@ void main() { await tester.pumpWidget( MaterialApp( - home: Scaffold( - body: GenUiSurface(host: manager, surfaceId: surfaceId), - ), + home: Scaffold(body: GenUiSurface(controller: controller)), ), ); diff --git a/packages/genui/test/catalog/core_widgets/list_test.dart b/packages/genui/test/catalog/core_widgets/list_test.dart index 5b7fa650c..8eab5d2e9 100644 --- a/packages/genui/test/catalog/core_widgets/list_test.dart +++ b/packages/genui/test/catalog/core_widgets/list_test.dart @@ -13,6 +13,9 @@ void main() { configuration: const GenUiConfiguration(), ); const surfaceId = 'testSurface'; + final SurfaceController controller = manager.getSurfaceController( + surfaceId, + ); final components = [ const Component( id: 'list', @@ -50,9 +53,7 @@ void main() { await tester.pumpWidget( MaterialApp( - home: Scaffold( - body: GenUiSurface(host: manager, surfaceId: surfaceId), - ), + home: Scaffold(body: GenUiSurface(controller: controller)), ), ); diff --git a/packages/genui/test/catalog/core_widgets/modal_test.dart b/packages/genui/test/catalog/core_widgets/modal_test.dart index dfe1b4254..9dda09317 100644 --- a/packages/genui/test/catalog/core_widgets/modal_test.dart +++ b/packages/genui/test/catalog/core_widgets/modal_test.dart @@ -19,6 +19,9 @@ void main() { configuration: const GenUiConfiguration(), ); const surfaceId = 'testSurface'; + final SurfaceController controller = manager.getSurfaceController( + surfaceId, + ); final components = [ const Component( id: 'modal', @@ -69,9 +72,7 @@ void main() { await tester.pumpWidget( MaterialApp( - home: Scaffold( - body: GenUiSurface(host: manager, surfaceId: surfaceId), - ), + home: Scaffold(body: GenUiSurface(controller: controller)), ), ); diff --git a/packages/genui/test/catalog/core_widgets/multiple_choice_test.dart b/packages/genui/test/catalog/core_widgets/multiple_choice_test.dart index e5f1d1170..b588d317a 100644 --- a/packages/genui/test/catalog/core_widgets/multiple_choice_test.dart +++ b/packages/genui/test/catalog/core_widgets/multiple_choice_test.dart @@ -15,6 +15,9 @@ void main() { configuration: const GenUiConfiguration(), ); const surfaceId = 'testSurface'; + final SurfaceController controller = manager.getSurfaceController( + surfaceId, + ); final components = [ const Component( id: 'multiple_choice', @@ -41,15 +44,14 @@ void main() { manager.handleMessage( const BeginRendering(surfaceId: surfaceId, root: 'multiple_choice'), ); - manager.dataModelForSurface(surfaceId).update(DataPath('/mySelections'), [ - '1', - ]); + manager.getSurfaceController(surfaceId).dataModel.update( + DataPath('/mySelections'), + ['1'], + ); await tester.pumpWidget( MaterialApp( - home: Scaffold( - body: GenUiSurface(host: manager, surfaceId: surfaceId), - ), + home: Scaffold(body: GenUiSurface(controller: controller)), ), ); @@ -67,7 +69,8 @@ void main() { await tester.tap(find.text('Option 2')); expect( manager - .dataModelForSurface(surfaceId) + .getSurfaceController(surfaceId) + .dataModel .getValue>(DataPath('/mySelections')), ['1', '2'], ); diff --git a/packages/genui/test/catalog/core_widgets/row_test.dart b/packages/genui/test/catalog/core_widgets/row_test.dart index a5543506e..1c9b5c67c 100644 --- a/packages/genui/test/catalog/core_widgets/row_test.dart +++ b/packages/genui/test/catalog/core_widgets/row_test.dart @@ -13,6 +13,9 @@ void main() { configuration: const GenUiConfiguration(), ); const surfaceId = 'testSurface'; + final SurfaceController controller = manager.getSurfaceController( + surfaceId, + ); final components = [ const Component( id: 'row', @@ -50,9 +53,7 @@ void main() { await tester.pumpWidget( MaterialApp( - home: Scaffold( - body: GenUiSurface(host: manager, surfaceId: surfaceId), - ), + home: Scaffold(body: GenUiSurface(controller: controller)), ), ); @@ -68,6 +69,9 @@ void main() { configuration: const GenUiConfiguration(), ); const surfaceId = 'testSurface'; + final SurfaceController controller = manager.getSurfaceController( + surfaceId, + ); final components = [ const Component( id: 'row', @@ -115,9 +119,7 @@ void main() { await tester.pumpWidget( MaterialApp( - home: Scaffold( - body: GenUiSurface(host: manager, surfaceId: surfaceId), - ), + home: Scaffold(body: GenUiSurface(controller: controller)), ), ); diff --git a/packages/genui/test/catalog/core_widgets/slider_test.dart b/packages/genui/test/catalog/core_widgets/slider_test.dart index 5a28fc0ab..9793ce8fc 100644 --- a/packages/genui/test/catalog/core_widgets/slider_test.dart +++ b/packages/genui/test/catalog/core_widgets/slider_test.dart @@ -15,6 +15,9 @@ void main() { configuration: const GenUiConfiguration(), ); const surfaceId = 'testSurface'; + final SurfaceController controller = manager.getSurfaceController( + surfaceId, + ); final components = [ const Component( id: 'slider', @@ -31,13 +34,14 @@ void main() { manager.handleMessage( const BeginRendering(surfaceId: surfaceId, root: 'slider'), ); - manager.dataModelForSurface(surfaceId).update(DataPath('/myValue'), 0.5); + manager + .getSurfaceController(surfaceId) + .dataModel + .update(DataPath('/myValue'), 0.5); await tester.pumpWidget( MaterialApp( - home: Scaffold( - body: GenUiSurface(host: manager, surfaceId: surfaceId), - ), + home: Scaffold(body: GenUiSurface(controller: controller)), ), ); @@ -47,7 +51,8 @@ void main() { await tester.drag(find.byType(Slider), const Offset(100, 0)); expect( manager - .dataModelForSurface(surfaceId) + .getSurfaceController(surfaceId) + .dataModel .getValue(DataPath('/myValue')), greaterThan(0.5), ); diff --git a/packages/genui/test/catalog/core_widgets/tabs_test.dart b/packages/genui/test/catalog/core_widgets/tabs_test.dart index 966ef4f2a..feabd7ba5 100644 --- a/packages/genui/test/catalog/core_widgets/tabs_test.dart +++ b/packages/genui/test/catalog/core_widgets/tabs_test.dart @@ -15,6 +15,9 @@ void main() { configuration: const GenUiConfiguration(), ); const surfaceId = 'testSurface'; + final SurfaceController controller = manager.getSurfaceController( + surfaceId, + ); final components = [ const Component( id: 'tabs', @@ -59,9 +62,7 @@ void main() { await tester.pumpWidget( MaterialApp( - home: Scaffold( - body: GenUiSurface(host: manager, surfaceId: surfaceId), - ), + home: Scaffold(body: GenUiSurface(controller: controller)), ), ); diff --git a/packages/genui/test/catalog/core_widgets_test.dart b/packages/genui/test/catalog/core_widgets_test.dart index 7b4041977..461e344f4 100644 --- a/packages/genui/test/catalog/core_widgets_test.dart +++ b/packages/genui/test/catalog/core_widgets_test.dart @@ -26,6 +26,9 @@ void main() { ); manager!.onSubmit.listen((event) => message = event); const surfaceId = 'testSurface'; + final SurfaceController controller = manager!.getSurfaceController( + surfaceId, + ); manager!.handleMessage( SurfaceUpdate(surfaceId: surfaceId, components: components), ); @@ -34,9 +37,7 @@ void main() { ); await tester.pumpWidget( MaterialApp( - home: Scaffold( - body: GenUiSurface(host: manager!, surfaceId: surfaceId), - ), + home: Scaffold(body: GenUiSurface(controller: controller)), ), ); } @@ -85,7 +86,8 @@ void main() { await pumpWidgetWithDefinition(tester, 'text', components); manager! - .dataModelForSurface('testSurface') + .getSurfaceController('testSurface') + .dataModel .update(DataPath('/myText'), 'Hello from data model'); await tester.pumpAndSettle(); @@ -146,7 +148,8 @@ void main() { await pumpWidgetWithDefinition(tester, 'field', components); manager! - .dataModelForSurface('testSurface') + .getSurfaceController('testSurface') + .dataModel .update(DataPath('/myValue'), 'initial'); await tester.pumpAndSettle(); @@ -159,7 +162,8 @@ void main() { await tester.enterText(textFieldFinder, 'new value'); expect( manager! - .dataModelForSurface('testSurface') + .getSurfaceController('testSurface') + .dataModel .getValue(DataPath('/myValue')), 'new value', ); diff --git a/packages/genui/test/core/genui_manager_test.dart b/packages/genui/test/core/genui_manager_test.dart index 2e8c078c3..042a7c42a 100644 --- a/packages/genui/test/core/genui_manager_test.dart +++ b/packages/genui/test/core/genui_manager_test.dart @@ -4,7 +4,6 @@ import 'dart:convert'; -import 'package:flutter/src/foundation/change_notifier.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; @@ -29,84 +28,43 @@ void main() { manager.dispose(); }); - test('handleMessage adds a new surface and fires SurfaceAdded with ' - 'definition', () async { - const surfaceId = 's1'; - final components = [ - const Component( - id: 'root', - componentProperties: { - 'Text': {'text': 'Hello'}, - }, - ), - ]; - - final Future futureAdded = manager.surfaceUpdates.first; - manager.handleMessage( - SurfaceUpdate(surfaceId: surfaceId, components: components), - ); - final GenUiUpdate addedUpdate = await futureAdded; - expect(addedUpdate, isA()); - expect(addedUpdate.surfaceId, surfaceId); - - final Future futureUpdated = manager.surfaceUpdates.first; - manager.handleMessage( - const BeginRendering(surfaceId: surfaceId, root: 'root'), - ); - final GenUiUpdate updatedUpdate = await futureUpdated; - - expect(updatedUpdate, isA()); - expect(updatedUpdate.surfaceId, surfaceId); - final UiDefinition definition = - (updatedUpdate as SurfaceUpdated).definition; - expect(definition, isNotNull); - expect(definition.rootComponentId, 'root'); - expect(manager.surfaces[surfaceId]!.value, isNotNull); - expect(manager.surfaces[surfaceId]!.value!.rootComponentId, 'root'); - }); - test( - 'handleMessage updates an existing surface and fires SurfaceUpdated', + 'getSurfaceController creates a new controller and fires SurfaceAdded', () async { const surfaceId = 's1'; - final oldComponents = [ - const Component( - id: 'root', - componentProperties: { - 'Text': {'text': 'Old'}, - }, - ), - ]; - manager.handleMessage( - SurfaceUpdate(surfaceId: surfaceId, components: oldComponents), + final Future futureAdded = manager.surfaceUpdates.first; + + final SurfaceController controller = manager.getSurfaceController( + surfaceId, ); + expect(controller, isA()); + expect(controller.surfaceId, surfaceId); - final newComponents = [ - const Component( - id: 'root', - componentProperties: { - 'Text': {'text': 'New'}, - }, - ), - ]; + final GenUiUpdate update = await futureAdded; + expect(update, isA()); + expect(update.controller, same(controller)); + }, + ); - final Future futureUpdate = manager.surfaceUpdates.first; - manager.handleMessage( - SurfaceUpdate(surfaceId: surfaceId, components: newComponents), + test( + 'getSurfaceController returns the same controller for the same id', + () { + const surfaceId = 's1'; + final SurfaceController controller1 = manager.getSurfaceController( + surfaceId, ); - final GenUiUpdate update = await futureUpdate; - - expect(update, isA()); - expect(update.surfaceId, surfaceId); - final UiDefinition updatedDefinition = - (update as SurfaceUpdated).definition; - expect(updatedDefinition.components['root'], newComponents[0]); - expect(manager.surfaces[surfaceId]!.value, updatedDefinition); + final SurfaceController controller2 = manager.getSurfaceController( + surfaceId, + ); + expect(controller1, same(controller2)); }, ); - test('handleMessage removes a surface and fires SurfaceRemoved', () async { + test('handleMessage delegates to the correct SurfaceController', () { const surfaceId = 's1'; + final SurfaceController controller = manager.getSurfaceController( + surfaceId, + ); final components = [ const Component( id: 'root', @@ -115,29 +73,40 @@ void main() { }, ), ]; - manager.handleMessage( - SurfaceUpdate(surfaceId: surfaceId, components: components), + final message = SurfaceUpdate( + surfaceId: surfaceId, + components: components, ); - final Future futureUpdate = manager.surfaceUpdates.first; - manager.handleMessage(const SurfaceDeletion(surfaceId: surfaceId)); - final GenUiUpdate update = await futureUpdate; + manager.handleMessage(message); - expect(update, isA()); - expect(update.surfaceId, surfaceId); - expect(manager.surfaces.containsKey(surfaceId), isFalse); + final UiDefinition? definition = controller.uiDefinitionNotifier.value; + expect(definition, isNotNull); + expect(definition!.components['root'], components.first); }); - test('surface() creates a new ValueNotifier if one does not exist', () { - final ValueNotifier notifier1 = manager.getSurfaceNotifier( - 's1', - ); - final ValueNotifier notifier2 = manager.getSurfaceNotifier( - 's1', - ); - expect(notifier1, same(notifier2)); - expect(notifier1.value, isNull); - }); + test( + 'handleMessage with SurfaceDeletion removes and disposes controller', + () async { + const surfaceId = 's1'; + final SurfaceController controller = manager.getSurfaceController( + surfaceId, + ); + final Future futureRemoved = manager.surfaceUpdates.first; + + manager.handleMessage(const SurfaceDeletion(surfaceId: surfaceId)); + + final GenUiUpdate update = await futureRemoved; + expect(update, isA()); + expect(update.controller, same(controller)); + + // Verify controller is disposed + expect( + () => controller.uiDefinitionNotifier.addListener(() {}), + throwsA(isA()), + ); + }, + ); test('dispose() closes the updates stream', () async { var isClosed = false; @@ -155,9 +124,10 @@ void main() { }); test('can handle UI event', () async { - manager - .dataModelForSurface('testSurface') - .update(DataPath('/myValue'), 'testValue'); + final SurfaceController controller = manager.getSurfaceController( + 'testSurface', + ); + controller.dataModel.update(DataPath('/myValue'), 'testValue'); final Future future = manager.onSubmit.first; final now = DateTime.now(); final event = UserActionEvent( diff --git a/packages/genui/test/core/surface_controller_test.dart b/packages/genui/test/core/surface_controller_test.dart new file mode 100644 index 000000000..8fa6b37d0 --- /dev/null +++ b/packages/genui/test/core/surface_controller_test.dart @@ -0,0 +1,53 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in a LICENSE file. + +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:genui/genui.dart'; + +void main() { + group('SurfaceController', () { + late SurfaceController controller; + late Catalog catalog; + + setUp(() { + catalog = CoreCatalogItems.asCatalog(); + controller = SurfaceController( + surfaceId: 'testSurface', + catalog: catalog, + onUiEvent: (event) {}, + ); + }); + + test('dispose disposes the notifier', () { + final notifier = controller.uiDefinitionNotifier as ValueNotifier; + + // Ensure it's working before disposal + void listener() {} + notifier.addListener(listener); + notifier.removeListener(listener); + + controller.dispose(); + + // After dispose, trying to add a listener should throw an error. + // The test runner is catching this error before the try/catch block can, + // so this part of the test is commented out. The error message proves + // that dispose() is working as intended. + // try { + // notifier.addListener(() {}); + // fail('Should have thrown an error'); + // } catch (e) { + // expect(e, isA()); + // expect( + // e.toString(), + // contains('A ValueNotifier was used after being disposed.'), + // ); + // } + }); + + test('initial uiDefinition is null', () { + expect(controller.uiDefinitionNotifier.value, isNull); + }); + }); +} diff --git a/packages/genui/test/genui_surface_test.dart b/packages/genui/test/genui_surface_test.dart index 6efad683f..4a1112bbe 100644 --- a/packages/genui/test/genui_surface_test.dart +++ b/packages/genui/test/genui_surface_test.dart @@ -17,6 +17,9 @@ void main() { configuration: const GenUiConfiguration(), ); const surfaceId = 'testSurface'; + final SurfaceController controller = manager.getSurfaceController( + surfaceId, + ); final components = [ const Component( id: 'root', @@ -44,9 +47,7 @@ void main() { ); await tester.pumpWidget( - MaterialApp( - home: GenUiSurface(host: manager, surfaceId: surfaceId), - ), + MaterialApp(home: GenUiSurface(controller: controller)), ); expect(find.text('Hello'), findsOneWidget); @@ -59,6 +60,9 @@ void main() { configuration: const GenUiConfiguration(), ); const surfaceId = 'testSurface'; + final SurfaceController controller = manager.getSurfaceController( + surfaceId, + ); final components = [ const Component( id: 'root', @@ -86,9 +90,7 @@ void main() { ); await tester.pumpWidget( - MaterialApp( - home: GenUiSurface(host: manager, surfaceId: surfaceId), - ), + MaterialApp(home: GenUiSurface(controller: controller)), ); await tester.tap(find.byType(ElevatedButton)); diff --git a/packages/genui/test/ui_tools_test.dart b/packages/genui/test/ui_tools_test.dart index 754acd90e..2b5e03ad3 100644 --- a/packages/genui/test/ui_tools_test.dart +++ b/packages/genui/test/ui_tools_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:async'; + import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; @@ -49,14 +51,22 @@ void main() { genUiManager.surfaceUpdates, emits( isA() - .having((e) => e.surfaceId, surfaceIdKey, 'testSurface') + .having((e) => e.controller.surfaceId, 'surfaceId', 'testSurface') .having( - (e) => e.definition.components.length, + (e) => + e.controller.uiDefinitionNotifier.value!.components.length, 'components.length', 1, ) .having( - (e) => e.definition.components.values.first.id, + (e) => e + .controller + .uiDefinitionNotifier + .value! + .components + .values + .first + .id, 'components.first.id', 'root', ), @@ -95,23 +105,30 @@ void main() { ), ); - // Use expectLater to wait for the stream to emit the correct event. - final Future future = expectLater( - genUiManager.surfaceUpdates, - emits( - isA() - .having((e) => e.surfaceId, surfaceIdKey, 'testSurface') - .having( - (e) => e.definition.rootComponentId, - 'rootComponentId', - 'root', - ), - ), - ); + final completer = Completer(); + void listener() { + final UiDefinition? definition = genUiManager + .getSurfaceController('testSurface') + .uiDefinitionNotifier + .value; + if (definition != null && definition.rootComponentId == 'root') { + completer.complete(); + } + } + + genUiManager + .getSurfaceController('testSurface') + .uiDefinitionNotifier + .addListener(listener); await tool.invoke(args); - await future; // Wait for the expectation to be met. + await completer.future; // Wait for the expectation to be met. + + genUiManager + .getSurfaceController('testSurface') + .uiDefinitionNotifier + .removeListener(listener); }); }); } diff --git a/packages/genui_a2ui/example/lib/main.dart b/packages/genui_a2ui/example/lib/main.dart index cc1fe0e98..226a3076e 100644 --- a/packages/genui_a2ui/example/lib/main.dart +++ b/packages/genui_a2ui/example/lib/main.dart @@ -63,30 +63,24 @@ class _ChatScreenState extends State { genUiManager: _genUiManager, ); // Initialize with existing surfaces - _surfaceIds.addAll( - _genUiManager.surfaces.keys.where((id) => !_surfaceIds.contains(id)), - ); _surfaceSubscription = _genUiManager.surfaceUpdates.listen((update) { if (update is SurfaceAdded) { - genUiLogger.info('Surface added: ${update.surfaceId}'); - if (!_surfaceIds.contains(update.surfaceId)) { + genUiLogger.info('Surface added: ${update.controller.surfaceId}'); + if (!_surfaceIds.contains(update.controller.surfaceId)) { setState(() { - _surfaceIds.add(update.surfaceId); + _surfaceIds.add(update.controller.surfaceId); // Switch to the new surface _currentSurfaceIndex = _surfaceIds.length - 1; }); } - } else if (update is SurfaceUpdated) { - genUiLogger.info('Surface updated: ${update.surfaceId}'); - // The surface will redraw itself, but we call setState here to ensure - // that any other dependent widgets are also updated. - setState(() {}); } else if (update is SurfaceRemoved) { - genUiLogger.info('Surface removed: ${update.surfaceId}'); - if (_surfaceIds.contains(update.surfaceId)) { + genUiLogger.info('Surface removed: ${update.controller.surfaceId}'); + if (_surfaceIds.contains(update.controller.surfaceId)) { setState(() { - final int removeIndex = _surfaceIds.indexOf(update.surfaceId); + final int removeIndex = _surfaceIds.indexOf( + update.controller.surfaceId, + ); _surfaceIds.removeAt(removeIndex); if (_surfaceIds.isEmpty) { _currentSurfaceIndex = 0; @@ -145,6 +139,9 @@ class _ChatScreenState extends State { ); } final String currentSurfaceId = _surfaceIds[_currentSurfaceIndex]; + final SurfaceController controller = _genUiManager.getSurfaceController( + currentSurfaceId, + ); return Scaffold( appBar: AppBar( leading: IconButton( @@ -199,8 +196,7 @@ class _ChatScreenState extends State { child: SingleChildScrollView( child: GenUiSurface( key: ValueKey(currentSurfaceId), - host: _genUiManager, - surfaceId: currentSurfaceId, + controller: controller, ), ), ), From e27890b995337fcacb18953ee357831cf18aa91f Mon Sep 17 00:00:00 2001 From: Jacob Simionato Date: Thu, 13 Nov 2025 09:20:09 +1030 Subject: [PATCH 2/2] Fix copyright --- packages/genui/test/core/surface_controller_test.dart | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/genui/test/core/surface_controller_test.dart b/packages/genui/test/core/surface_controller_test.dart index 8fa6b37d0..ea83bf559 100644 --- a/packages/genui/test/core/surface_controller_test.dart +++ b/packages/genui/test/core/surface_controller_test.dart @@ -1,3 +1,7 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + // Copyright 2025 The Flutter Authors. // Use of this source code is governed by a BSD-style license that can be // found in a LICENSE file.