From cafcd034fd6e7d1a45176db5616fe37372da893e Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Fri, 21 Nov 2025 11:22:49 -0800 Subject: [PATCH 01/11] Add sample browser to catalog_gallery app --- examples/catalog_gallery/.metadata | 12 +- examples/catalog_gallery/lib/main.dart | 79 ++++-- .../catalog_gallery/lib/sample_parser.dart | 66 +++++ .../catalog_gallery/lib/samples_view.dart | 242 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + .../macos/Runner/DebugProfile.entitlements | 2 +- examples/catalog_gallery/pubspec.yaml | 2 + .../samples/hello_world.sample | 5 + .../test/sample_parser_test.dart | 50 ++++ .../flutter/generated_plugin_registrant.cc | 11 + .../flutter/generated_plugin_registrant.h | 15 ++ .../linux/flutter/generated_plugins.cmake | 23 ++ .../Flutter/GeneratedPluginRegistrant.swift | 4 - .../flutter/generated_plugin_registrant.cc | 4 - .../flutter/generated_plugin_registrant.h | 4 - .../windows/flutter/generated_plugins.cmake | 4 - .../flutter/generated_plugin_registrant.cc | 11 + .../flutter/generated_plugin_registrant.h | 15 ++ .../linux/flutter/generated_plugins.cmake | 23 ++ .../flutter/generated_plugin_registrant.cc | 4 - .../flutter/generated_plugin_registrant.h | 4 - .../linux/flutter/generated_plugins.cmake | 4 - .../Flutter/GeneratedPluginRegistrant.swift | 4 - .../flutter/generated_plugin_registrant.cc | 11 + .../flutter/generated_plugin_registrant.h | 15 ++ .../linux/flutter/generated_plugins.cmake | 23 ++ .../Flutter/GeneratedPluginRegistrant.swift | 10 + .../flutter/generated_plugin_registrant.cc | 11 + .../flutter/generated_plugin_registrant.h | 15 ++ .../windows/flutter/generated_plugins.cmake | 23 ++ 30 files changed, 645 insertions(+), 58 deletions(-) create mode 100644 examples/catalog_gallery/lib/sample_parser.dart create mode 100644 examples/catalog_gallery/lib/samples_view.dart create mode 100644 examples/catalog_gallery/macos/Runner.xcworkspace/contents.xcworkspacedata create mode 100644 examples/catalog_gallery/samples/hello_world.sample create mode 100644 examples/catalog_gallery/test/sample_parser_test.dart create mode 100644 examples/simple_chat/linux/flutter/generated_plugin_registrant.cc create mode 100644 examples/simple_chat/linux/flutter/generated_plugin_registrant.h create mode 100644 examples/simple_chat/linux/flutter/generated_plugins.cmake create mode 100644 examples/travel_app/linux/flutter/generated_plugin_registrant.cc create mode 100644 examples/travel_app/linux/flutter/generated_plugin_registrant.h create mode 100644 examples/travel_app/linux/flutter/generated_plugins.cmake create mode 100644 packages/genui_a2ui/example/linux/flutter/generated_plugin_registrant.cc create mode 100644 packages/genui_a2ui/example/linux/flutter/generated_plugin_registrant.h create mode 100644 packages/genui_a2ui/example/linux/flutter/generated_plugins.cmake create mode 100644 packages/genui_a2ui/example/macos/Flutter/GeneratedPluginRegistrant.swift create mode 100644 packages/genui_a2ui/example/windows/flutter/generated_plugin_registrant.cc create mode 100644 packages/genui_a2ui/example/windows/flutter/generated_plugin_registrant.h create mode 100644 packages/genui_a2ui/example/windows/flutter/generated_plugins.cmake diff --git a/examples/catalog_gallery/.metadata b/examples/catalog_gallery/.metadata index b25d10412..298107264 100644 --- a/examples/catalog_gallery/.metadata +++ b/examples/catalog_gallery/.metadata @@ -4,8 +4,8 @@ # This file should be version controlled and should not be manually edited. version: - revision: "e11e2c11288b6a3f9f9bb3dcfb9bb459a75e048c" - channel: "main" + revision: "b45fa18946ecc2d9b4009952c636ba7e2ffbb787" + channel: "stable" project_type: app @@ -13,11 +13,11 @@ project_type: app migration: platforms: - platform: root - create_revision: e11e2c11288b6a3f9f9bb3dcfb9bb459a75e048c - base_revision: e11e2c11288b6a3f9f9bb3dcfb9bb459a75e048c + create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787 + base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787 - platform: macos - create_revision: e11e2c11288b6a3f9f9bb3dcfb9bb459a75e048c - base_revision: e11e2c11288b6a3f9f9bb3dcfb9bb459a75e048c + create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787 + base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787 # User provided section diff --git a/examples/catalog_gallery/lib/main.dart b/examples/catalog_gallery/lib/main.dart index fc452a48b..fa587cb02 100644 --- a/examples/catalog_gallery/lib/main.dart +++ b/examples/catalog_gallery/lib/main.dart @@ -3,16 +3,37 @@ // found in the LICENSE file. import 'dart:convert'; +import 'dart:io'; +import 'package:args/args.dart'; import 'package:flutter/material.dart'; import 'package:genui/genui.dart'; -void main() { - runApp(const CatalogGalleryApp()); +import 'samples_view.dart'; + +void main(List args) { + final parser = ArgParser() + ..addOption('samples', abbr: 's', help: 'Path to the samples directory'); + final ArgResults results = parser.parse(args); + + Directory? samplesDir; + if (results.wasParsed('samples')) { + samplesDir = Directory(results['samples'] as String); + } else { + final Directory current = Directory.current; + final defaultSamples = Directory('${current.path}/samples'); + if (defaultSamples.existsSync()) { + samplesDir = defaultSamples; + } + } + + runApp(CatalogGalleryApp(samplesDir: samplesDir)); } class CatalogGalleryApp extends StatefulWidget { - const CatalogGalleryApp({super.key}); + final Directory? samplesDir; + + const CatalogGalleryApp({super.key, this.samplesDir}); @override State createState() => _CatalogGalleryAppState(); @@ -23,27 +44,47 @@ class _CatalogGalleryAppState extends State { @override Widget build(BuildContext context) { + final bool showSamples = + widget.samplesDir != null && widget.samplesDir!.existsSync(); + return MaterialApp( theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), ), - home: Scaffold( - appBar: AppBar( - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - title: const Text('Catalog items that has "exampleData" field set'), - ), - body: DebugCatalogView( - catalog: catalog, - onSubmit: (message) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'User action: ' - '${jsonEncode(message.parts.last)}', - ), + home: DefaultTabController( + length: showSamples ? 2 : 1, + child: Scaffold( + appBar: AppBar( + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + title: const Text('Catalog Gallery'), + bottom: showSamples + ? const TabBar( + tabs: [ + Tab(text: 'Catalog'), + Tab(text: 'Samples'), + ], + ) + : null, + ), + body: TabBarView( + children: [ + DebugCatalogView( + catalog: catalog, + onSubmit: (message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'User action: ' + '${jsonEncode(message.parts.last)}', + ), + ), + ); + }, ), - ); - }, + if (showSamples) + SamplesView(samplesDir: widget.samplesDir!, catalog: catalog), + ], + ), ), ), ); diff --git a/examples/catalog_gallery/lib/sample_parser.dart b/examples/catalog_gallery/lib/sample_parser.dart new file mode 100644 index 000000000..c34da2f04 --- /dev/null +++ b/examples/catalog_gallery/lib/sample_parser.dart @@ -0,0 +1,66 @@ +// 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 'dart:convert'; +import 'dart:io'; + +import 'package:genui/genui.dart'; +import 'package:yaml/yaml.dart'; + +class Sample { + final String name; + final String description; + final Stream messages; + + Sample({ + required this.name, + required this.description, + required this.messages, + }); +} + +class SampleParser { + static Future parseFile(File file) async { + final String content = await file.readAsString(); + return parseString(content); + } + + static Sample parseString(String content) { + final List parts = content.split('---'); + if (parts.length < 2) { + throw const FormatException( + 'Sample file must contain a YAML header and a JSONL body separated by "---"', + ); + } + + final String yamlHeader = parts[0]; + final String jsonlBody = parts + .sublist(1) + .join('---'); // Rejoin in case body contains "---" + + final header = loadYaml(yamlHeader) as YamlMap; + final String name = header['name'] as String? ?? 'Untitled Sample'; + final String description = header['description'] as String? ?? ''; + + final Stream messages = Stream.fromIterable( + const LineSplitter() + .convert(jsonlBody) + .where((line) => line.trim().isNotEmpty) + .map((line) { + try { + final dynamic json = jsonDecode(line); + if (json is Map) { + return A2uiMessage.fromJson(json); + } + throw FormatException('Invalid JSON line: $line'); + } catch (e) { + print('Error parsing line: $line, error: $e'); + rethrow; + } + }), + ); + + return Sample(name: name, description: description, messages: messages); + } +} diff --git a/examples/catalog_gallery/lib/samples_view.dart b/examples/catalog_gallery/lib/samples_view.dart new file mode 100644 index 000000000..40703f502 --- /dev/null +++ b/examples/catalog_gallery/lib/samples_view.dart @@ -0,0 +1,242 @@ +// 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 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:genui/genui.dart'; +import 'package:path/path.dart' as path; + +import 'sample_parser.dart'; + +class SamplesView extends StatefulWidget { + final Directory samplesDir; + final Catalog catalog; + + const SamplesView({ + super.key, + required this.samplesDir, + required this.catalog, + }); + + @override + State createState() => _SamplesViewState(); +} + +class _SamplesViewState extends State { + List _sampleFiles = []; + File? _selectedFile; + Sample? _selectedSample; + GenUiManager _genUiManager = GenUiManager( + catalog: CoreCatalogItems.asCatalog(), + ); + final List _surfaceIds = []; + int _currentSurfaceIndex = 0; + StreamSubscription? _surfaceSubscription; + StreamSubscription? _messageSubscription; + + @override + void initState() { + super.initState(); + _loadSamples(); + _setupSurfaceListener(); + } + + @override + void dispose() { + _surfaceSubscription?.cancel(); + _messageSubscription?.cancel(); + _genUiManager.dispose(); + super.dispose(); + } + + void _setupSurfaceListener() { + _surfaceSubscription = _genUiManager.surfaceUpdates.listen((update) { + if (update is SurfaceAdded) { + if (!_surfaceIds.contains(update.surfaceId)) { + setState(() { + _surfaceIds.add(update.surfaceId); + // If this is the first surface, select it. + if (_surfaceIds.length == 1) { + _currentSurfaceIndex = 0; + } + }); + } + } else if (update is SurfaceUpdated) { + setState(() {}); + } else if (update is SurfaceRemoved) { + if (_surfaceIds.contains(update.surfaceId)) { + setState(() { + final int removeIndex = _surfaceIds.indexOf(update.surfaceId); + _surfaceIds.removeAt(removeIndex); + if (_surfaceIds.isEmpty) { + _currentSurfaceIndex = 0; + } else { + if (_currentSurfaceIndex >= removeIndex && + _currentSurfaceIndex > 0) { + _currentSurfaceIndex--; + } + if (_currentSurfaceIndex >= _surfaceIds.length) { + _currentSurfaceIndex = _surfaceIds.length - 1; + } + } + }); + } + } + }); + } + + Future _loadSamples() async { + if (!widget.samplesDir.existsSync()) { + return; + } + final List files = widget.samplesDir + .listSync() + .whereType() + .where((f) => f.path.endsWith('.sample')) + .toList(); + setState(() { + _sampleFiles = files; + }); + } + + Future _selectSample(File file) async { + _messageSubscription?.cancel(); + // Reset surfaces + setState(() { + _surfaceIds.clear(); + _currentSurfaceIndex = 0; + }); + // Re-create GenUiManager to ensure a clean state for the new sample. + _genUiManager.dispose(); + _genUiManager = GenUiManager(catalog: CoreCatalogItems.asCatalog()); + _setupSurfaceListener(); + + try { + final Sample sample = await SampleParser.parseFile(file); + setState(() { + _selectedFile = file; + _selectedSample = sample; + }); + + _messageSubscription = sample.messages.listen( + _genUiManager.handleMessage, + onError: (Object e) { + print('Error processing message: $e'); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error processing sample: $e')), + ); + }, + ); + } catch (e) { + print('Error parsing sample: $e'); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Error parsing sample: $e'))); + } + } + + @override + Widget build(BuildContext context) { + return Row( + children: [ + // Left pane: Sample List + SizedBox( + width: 250, + child: Column( + children: [ + AppBar( + title: const Text('Samples'), + automaticallyImplyLeading: false, + elevation: 0, + ), + Expanded( + child: ListView.builder( + itemCount: _sampleFiles.length, + itemBuilder: (context, index) { + final File file = _sampleFiles[index]; + final String fileName = path.basename(file.path); + + return ListTile( + title: Text(fileName), + selected: + _selectedFile?.path == + file.path, // Compare file paths for selection + onTap: () => _selectSample(file), + ); + }, + ), + ), + ], + ), + ), + const VerticalDivider(width: 1), + // Right pane: Canvas / Surfaces + Expanded( + child: _selectedSample == null + ? const Center(child: Text('Sample')) + : Column( + children: [ + // Surface Tabs + if (_surfaceIds.isNotEmpty) + SizedBox( + height: 50, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: _surfaceIds.length, + itemBuilder: (context, index) { + final String id = _surfaceIds[index]; + final isSelected = index == _currentSurfaceIndex; + return InkWell( + onTap: () { + setState(() { + _currentSurfaceIndex = index; + }); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + color: isSelected + ? Theme.of( + context, + ).primaryColor.withOpacity(0.1) + : null, + alignment: Alignment.center, + child: Text( + id, + style: TextStyle( + fontWeight: isSelected + ? FontWeight.bold + : FontWeight.normal, + color: isSelected + ? Theme.of(context).primaryColor + : null, + ), + ), + ), + ); + }, + ), + ), + const Divider(height: 1), + // Surface Content + Expanded( + child: _surfaceIds.isEmpty + ? const Center(child: Text('No surfaces')) + : GenUiSurface( + key: ValueKey(_surfaceIds[_currentSurfaceIndex]), + host: _genUiManager, + surfaceId: _surfaceIds[_currentSurfaceIndex], + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/examples/catalog_gallery/macos/Runner.xcworkspace/contents.xcworkspacedata b/examples/catalog_gallery/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..1d526a16e --- /dev/null +++ b/examples/catalog_gallery/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/examples/catalog_gallery/macos/Runner/DebugProfile.entitlements b/examples/catalog_gallery/macos/Runner/DebugProfile.entitlements index c946719a1..c672c0475 100644 --- a/examples/catalog_gallery/macos/Runner/DebugProfile.entitlements +++ b/examples/catalog_gallery/macos/Runner/DebugProfile.entitlements @@ -3,7 +3,7 @@ com.apple.security.app-sandbox - + com.apple.security.cs.allow-jit com.apple.security.network.server diff --git a/examples/catalog_gallery/pubspec.yaml b/examples/catalog_gallery/pubspec.yaml index fab818dfe..5c2b9f834 100644 --- a/examples/catalog_gallery/pubspec.yaml +++ b/examples/catalog_gallery/pubspec.yaml @@ -13,10 +13,12 @@ environment: resolution: workspace dependencies: + args: ^2.7.0 flutter: sdk: flutter genui: ^0.5.1 json_schema_builder: ^0.1.3 + yaml: ^3.1.3 dev_dependencies: flutter_test: diff --git a/examples/catalog_gallery/samples/hello_world.sample b/examples/catalog_gallery/samples/hello_world.sample new file mode 100644 index 000000000..16cd345cb --- /dev/null +++ b/examples/catalog_gallery/samples/hello_world.sample @@ -0,0 +1,5 @@ +name: Test Sample +description: This is a test sample to verify the parser. +--- +{"surfaceUpdate": {"surfaceId": "default", "components": [{"id": "text1", "component": {"Text": {"text": {"literalString": "Hello World"}}}}]}} +{"beginRendering": {"surfaceId": "default", "root": "text1"}} diff --git a/examples/catalog_gallery/test/sample_parser_test.dart b/examples/catalog_gallery/test/sample_parser_test.dart new file mode 100644 index 000000000..da3ea0e9a --- /dev/null +++ b/examples/catalog_gallery/test/sample_parser_test.dart @@ -0,0 +1,50 @@ +// 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:catalog_gallery/sample_parser.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:genui/genui.dart'; + +void main() { + test('SampleParser parses valid sample string', () async { + const sampleContent = ''' +name: Test Sample +description: A test description +--- +{"surfaceUpdate": {"surfaceId": "default", "components": [{"id": "text1", "component": {"Text": {"text": {"literalString": "Hello"}}}}]}} +{"beginRendering": {"surfaceId": "default", "root": "text1"}} +'''; + + final Sample sample = SampleParser.parseString(sampleContent); + + expect(sample.name, 'Test Sample'); + expect(sample.description, 'A test description'); + + final List messages = await sample.messages.toList(); + expect(messages.length, 2); + expect(messages.first, isA()); + expect(messages.last, isA()); + + final update = messages.first as SurfaceUpdate; + expect(update.surfaceId, 'default'); + expect(update.components.length, 1); + expect(update.components.first.type, 'Text'); + + final begin = messages.last as BeginRendering; + expect(begin.surfaceId, 'default'); + expect(begin.root, 'text1'); + }); + + test('SampleParser throws on missing separator', () { + const sampleContent = ''' +name: Invalid Sample +No separator here +'''; + + expect( + () => SampleParser.parseString(sampleContent), + throwsFormatException, + ); + }); +} diff --git a/examples/simple_chat/linux/flutter/generated_plugin_registrant.cc b/examples/simple_chat/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 000000000..e71a16d23 --- /dev/null +++ b/examples/simple_chat/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,11 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + + +void fl_register_plugins(FlPluginRegistry* registry) { +} diff --git a/examples/simple_chat/linux/flutter/generated_plugin_registrant.h b/examples/simple_chat/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 000000000..e0f0a47bc --- /dev/null +++ b/examples/simple_chat/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/examples/simple_chat/linux/flutter/generated_plugins.cmake b/examples/simple_chat/linux/flutter/generated_plugins.cmake new file mode 100644 index 000000000..2e1de87a7 --- /dev/null +++ b/examples/simple_chat/linux/flutter/generated_plugins.cmake @@ -0,0 +1,23 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/examples/simple_chat/macos/Flutter/GeneratedPluginRegistrant.swift b/examples/simple_chat/macos/Flutter/GeneratedPluginRegistrant.swift index e5d2c93d9..c6c180db8 100644 --- a/examples/simple_chat/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/examples/simple_chat/macos/Flutter/GeneratedPluginRegistrant.swift @@ -1,7 +1,3 @@ -// 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. - // // Generated file. Do not edit. // diff --git a/examples/simple_chat/windows/flutter/generated_plugin_registrant.cc b/examples/simple_chat/windows/flutter/generated_plugin_registrant.cc index dc93d0ce0..d141b74f5 100644 --- a/examples/simple_chat/windows/flutter/generated_plugin_registrant.cc +++ b/examples/simple_chat/windows/flutter/generated_plugin_registrant.cc @@ -1,7 +1,3 @@ -// 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. - // // Generated file. Do not edit. // diff --git a/examples/simple_chat/windows/flutter/generated_plugin_registrant.h b/examples/simple_chat/windows/flutter/generated_plugin_registrant.h index 35e206378..dc139d85a 100644 --- a/examples/simple_chat/windows/flutter/generated_plugin_registrant.h +++ b/examples/simple_chat/windows/flutter/generated_plugin_registrant.h @@ -1,7 +1,3 @@ -// 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. - // // Generated file. Do not edit. // diff --git a/examples/simple_chat/windows/flutter/generated_plugins.cmake b/examples/simple_chat/windows/flutter/generated_plugins.cmake index 7036e9bee..29944d5b1 100644 --- a/examples/simple_chat/windows/flutter/generated_plugins.cmake +++ b/examples/simple_chat/windows/flutter/generated_plugins.cmake @@ -1,7 +1,3 @@ -# 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. - # # Generated file, do not edit. # diff --git a/examples/travel_app/linux/flutter/generated_plugin_registrant.cc b/examples/travel_app/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 000000000..e71a16d23 --- /dev/null +++ b/examples/travel_app/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,11 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + + +void fl_register_plugins(FlPluginRegistry* registry) { +} diff --git a/examples/travel_app/linux/flutter/generated_plugin_registrant.h b/examples/travel_app/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 000000000..e0f0a47bc --- /dev/null +++ b/examples/travel_app/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/examples/travel_app/linux/flutter/generated_plugins.cmake b/examples/travel_app/linux/flutter/generated_plugins.cmake new file mode 100644 index 000000000..2e1de87a7 --- /dev/null +++ b/examples/travel_app/linux/flutter/generated_plugins.cmake @@ -0,0 +1,23 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/examples/verdure/client/linux/flutter/generated_plugin_registrant.cc b/examples/verdure/client/linux/flutter/generated_plugin_registrant.cc index 2fceabc85..64a0ecea4 100644 --- a/examples/verdure/client/linux/flutter/generated_plugin_registrant.cc +++ b/examples/verdure/client/linux/flutter/generated_plugin_registrant.cc @@ -1,7 +1,3 @@ -// 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. - // // Generated file. Do not edit. // diff --git a/examples/verdure/client/linux/flutter/generated_plugin_registrant.h b/examples/verdure/client/linux/flutter/generated_plugin_registrant.h index c534c90dc..e0f0a47bc 100644 --- a/examples/verdure/client/linux/flutter/generated_plugin_registrant.h +++ b/examples/verdure/client/linux/flutter/generated_plugin_registrant.h @@ -1,7 +1,3 @@ -// 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. - // // Generated file. Do not edit. // diff --git a/examples/verdure/client/linux/flutter/generated_plugins.cmake b/examples/verdure/client/linux/flutter/generated_plugins.cmake index b41bed70d..2db3c22ae 100644 --- a/examples/verdure/client/linux/flutter/generated_plugins.cmake +++ b/examples/verdure/client/linux/flutter/generated_plugins.cmake @@ -1,7 +1,3 @@ -# 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. - # # Generated file, do not edit. # diff --git a/examples/verdure/client/macos/Flutter/GeneratedPluginRegistrant.swift b/examples/verdure/client/macos/Flutter/GeneratedPluginRegistrant.swift index 4eb8ecb81..542a28a58 100644 --- a/examples/verdure/client/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/examples/verdure/client/macos/Flutter/GeneratedPluginRegistrant.swift @@ -1,7 +1,3 @@ -// 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. - // // Generated file. Do not edit. // diff --git a/packages/genui_a2ui/example/linux/flutter/generated_plugin_registrant.cc b/packages/genui_a2ui/example/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 000000000..e71a16d23 --- /dev/null +++ b/packages/genui_a2ui/example/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,11 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + + +void fl_register_plugins(FlPluginRegistry* registry) { +} diff --git a/packages/genui_a2ui/example/linux/flutter/generated_plugin_registrant.h b/packages/genui_a2ui/example/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 000000000..e0f0a47bc --- /dev/null +++ b/packages/genui_a2ui/example/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/packages/genui_a2ui/example/linux/flutter/generated_plugins.cmake b/packages/genui_a2ui/example/linux/flutter/generated_plugins.cmake new file mode 100644 index 000000000..2e1de87a7 --- /dev/null +++ b/packages/genui_a2ui/example/linux/flutter/generated_plugins.cmake @@ -0,0 +1,23 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/packages/genui_a2ui/example/macos/Flutter/GeneratedPluginRegistrant.swift b/packages/genui_a2ui/example/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 000000000..cccf817a5 --- /dev/null +++ b/packages/genui_a2ui/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,10 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { +} diff --git a/packages/genui_a2ui/example/windows/flutter/generated_plugin_registrant.cc b/packages/genui_a2ui/example/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 000000000..8b6d4680a --- /dev/null +++ b/packages/genui_a2ui/example/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,11 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + + +void RegisterPlugins(flutter::PluginRegistry* registry) { +} diff --git a/packages/genui_a2ui/example/windows/flutter/generated_plugin_registrant.h b/packages/genui_a2ui/example/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 000000000..dc139d85a --- /dev/null +++ b/packages/genui_a2ui/example/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/packages/genui_a2ui/example/windows/flutter/generated_plugins.cmake b/packages/genui_a2ui/example/windows/flutter/generated_plugins.cmake new file mode 100644 index 000000000..b93c4c30c --- /dev/null +++ b/packages/genui_a2ui/example/windows/flutter/generated_plugins.cmake @@ -0,0 +1,23 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) From 93429390dfdb98ddc0e259de02ef82730ada43d4 Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Fri, 21 Nov 2025 11:58:23 -0800 Subject: [PATCH 02/11] Use file package for tests --- examples/catalog_gallery/lib/main.dart | 20 +++++++---- .../catalog_gallery/lib/sample_parser.dart | 2 +- .../catalog_gallery/lib/samples_view.dart | 8 +++-- examples/catalog_gallery/pubspec.yaml | 1 + .../catalog_gallery/test/widget_test.dart | 35 ++++++++++++++++++- 5 files changed, 54 insertions(+), 12 deletions(-) diff --git a/examples/catalog_gallery/lib/main.dart b/examples/catalog_gallery/lib/main.dart index fa587cb02..b25d0025b 100644 --- a/examples/catalog_gallery/lib/main.dart +++ b/examples/catalog_gallery/lib/main.dart @@ -3,9 +3,9 @@ // found in the LICENSE file. import 'dart:convert'; -import 'dart:io'; - import 'package:args/args.dart'; +import 'package:file/file.dart'; +import 'package:file/local.dart'; import 'package:flutter/material.dart'; import 'package:genui/genui.dart'; @@ -16,24 +16,30 @@ void main(List args) { ..addOption('samples', abbr: 's', help: 'Path to the samples directory'); final ArgResults results = parser.parse(args); + const FileSystem fs = LocalFileSystem(); Directory? samplesDir; if (results.wasParsed('samples')) { - samplesDir = Directory(results['samples'] as String); + samplesDir = fs.directory(results['samples'] as String); } else { - final Directory current = Directory.current; - final defaultSamples = Directory('${current.path}/samples'); + final Directory current = fs.currentDirectory; + final Directory defaultSamples = fs.directory('${current.path}/samples'); if (defaultSamples.existsSync()) { samplesDir = defaultSamples; } } - runApp(CatalogGalleryApp(samplesDir: samplesDir)); + runApp(CatalogGalleryApp(samplesDir: samplesDir, fs: fs)); } class CatalogGalleryApp extends StatefulWidget { final Directory? samplesDir; + final FileSystem fs; - const CatalogGalleryApp({super.key, this.samplesDir}); + const CatalogGalleryApp({ + super.key, + this.samplesDir, + this.fs = const LocalFileSystem(), + }); @override State createState() => _CatalogGalleryAppState(); diff --git a/examples/catalog_gallery/lib/sample_parser.dart b/examples/catalog_gallery/lib/sample_parser.dart index c34da2f04..e4b98abc4 100644 --- a/examples/catalog_gallery/lib/sample_parser.dart +++ b/examples/catalog_gallery/lib/sample_parser.dart @@ -3,8 +3,8 @@ // found in the LICENSE file. import 'dart:convert'; -import 'dart:io'; +import 'package:file/file.dart'; import 'package:genui/genui.dart'; import 'package:yaml/yaml.dart'; diff --git a/examples/catalog_gallery/lib/samples_view.dart b/examples/catalog_gallery/lib/samples_view.dart index 40703f502..1c3f6baf6 100644 --- a/examples/catalog_gallery/lib/samples_view.dart +++ b/examples/catalog_gallery/lib/samples_view.dart @@ -3,22 +3,24 @@ // found in the LICENSE file. import 'dart:async'; -import 'dart:io'; +import 'package:file/file.dart'; +import 'package:file/local.dart'; import 'package:flutter/material.dart'; import 'package:genui/genui.dart'; -import 'package:path/path.dart' as path; import 'sample_parser.dart'; class SamplesView extends StatefulWidget { final Directory samplesDir; final Catalog catalog; + final FileSystem fs; const SamplesView({ super.key, required this.samplesDir, required this.catalog, + this.fs = const LocalFileSystem(), }); @override @@ -157,7 +159,7 @@ class _SamplesViewState extends State { itemCount: _sampleFiles.length, itemBuilder: (context, index) { final File file = _sampleFiles[index]; - final String fileName = path.basename(file.path); + final String fileName = widget.fs.path.basename(file.path); return ListTile( title: Text(fileName), diff --git a/examples/catalog_gallery/pubspec.yaml b/examples/catalog_gallery/pubspec.yaml index 5c2b9f834..2b9381583 100644 --- a/examples/catalog_gallery/pubspec.yaml +++ b/examples/catalog_gallery/pubspec.yaml @@ -14,6 +14,7 @@ resolution: workspace dependencies: args: ^2.7.0 + file: ^7.0.1 flutter: sdk: flutter genui: ^0.5.1 diff --git a/examples/catalog_gallery/test/widget_test.dart b/examples/catalog_gallery/test/widget_test.dart index 44dc80ddf..4ec78e634 100644 --- a/examples/catalog_gallery/test/widget_test.dart +++ b/examples/catalog_gallery/test/widget_test.dart @@ -3,11 +3,44 @@ // found in the LICENSE file. import 'package:catalog_gallery/main.dart'; +import 'package:file/memory.dart'; +import 'package:file/src/interface/directory.dart'; +import 'package:file/src/interface/file.dart'; + import 'package:flutter_test/flutter_test.dart'; void main() { testWidgets('Smoke test', (WidgetTester tester) async { + final fs = MemoryFileSystem(); // Build the app and trigger a frame. - await tester.pumpWidget(const CatalogGalleryApp()); + await tester.pumpWidget(CatalogGalleryApp(fs: fs)); + expect(find.text('Catalog Gallery'), findsOneWidget); + }); + + testWidgets('Loads samples from MemoryFileSystem', ( + WidgetTester tester, + ) async { + final fs = MemoryFileSystem(); + final Directory samplesDir = fs.directory('/samples')..createSync(); + final File sampleFile = samplesDir.childFile('test.sample'); + sampleFile.writeAsStringSync(''' +name: Test Sample +description: A test description +--- +{"surfaceUpdate": {"surfaceId": "default", "components": [{"id": "text1", "component": {"Text": {"text": {"literalString": "Hello"}}}}]}} +'''); + + await tester.pumpWidget(CatalogGalleryApp(samplesDir: samplesDir, fs: fs)); + await tester.pumpAndSettle(); + + // Verify that the "Samples" tab is present (since we provided a valid samplesDir) + expect(find.text('Samples'), findsOneWidget); + + // Tap on the Samples tab + await tester.tap(find.text('Samples')); + await tester.pumpAndSettle(); + + // Verify that the sample file is listed + expect(find.text('test.sample'), findsOneWidget); }); } From 1d0d336fffb92456b4916b7fd26056dd155e469f Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Fri, 21 Nov 2025 12:10:55 -0800 Subject: [PATCH 03/11] Review changes --- examples/catalog_gallery/lib/main.dart | 6 +++- .../catalog_gallery/lib/sample_parser.dart | 17 ++++++----- .../catalog_gallery/lib/samples_view.dart | 29 +++++++++---------- .../catalog_gallery/test/widget_test.dart | 7 +++-- 4 files changed, 31 insertions(+), 28 deletions(-) diff --git a/examples/catalog_gallery/lib/main.dart b/examples/catalog_gallery/lib/main.dart index b25d0025b..13bc8f466 100644 --- a/examples/catalog_gallery/lib/main.dart +++ b/examples/catalog_gallery/lib/main.dart @@ -88,7 +88,11 @@ class _CatalogGalleryAppState extends State { }, ), if (showSamples) - SamplesView(samplesDir: widget.samplesDir!, catalog: catalog), + SamplesView( + samplesDir: widget.samplesDir!, + catalog: catalog, + fs: widget.fs, + ), ], ), ), diff --git a/examples/catalog_gallery/lib/sample_parser.dart b/examples/catalog_gallery/lib/sample_parser.dart index e4b98abc4..e913201c9 100644 --- a/examples/catalog_gallery/lib/sample_parser.dart +++ b/examples/catalog_gallery/lib/sample_parser.dart @@ -27,17 +27,18 @@ class SampleParser { } static Sample parseString(String content) { - final List parts = content.split('---'); - if (parts.length < 2) { + final List lines = const LineSplitter().convert(content); + final int separatorIndex = lines.indexOf('---'); + + if (separatorIndex == -1) { throw const FormatException( - 'Sample file must contain a YAML header and a JSONL body separated by "---"', + 'Sample file must contain a YAML header and a JSONL body separated ' + 'by "---"', ); } - final String yamlHeader = parts[0]; - final String jsonlBody = parts - .sublist(1) - .join('---'); // Rejoin in case body contains "---" + final String yamlHeader = lines.sublist(0, separatorIndex).join('\n'); + final String jsonlBody = lines.sublist(separatorIndex + 1).join('\n'); final header = loadYaml(yamlHeader) as YamlMap; final String name = header['name'] as String? ?? 'Untitled Sample'; @@ -54,7 +55,7 @@ class SampleParser { return A2uiMessage.fromJson(json); } throw FormatException('Invalid JSON line: $line'); - } catch (e) { + } on FormatException catch (e) { print('Error parsing line: $line, error: $e'); rethrow; } diff --git a/examples/catalog_gallery/lib/samples_view.dart b/examples/catalog_gallery/lib/samples_view.dart index 1c3f6baf6..dc53dcef3 100644 --- a/examples/catalog_gallery/lib/samples_view.dart +++ b/examples/catalog_gallery/lib/samples_view.dart @@ -12,10 +12,6 @@ import 'package:genui/genui.dart'; import 'sample_parser.dart'; class SamplesView extends StatefulWidget { - final Directory samplesDir; - final Catalog catalog; - final FileSystem fs; - const SamplesView({ super.key, required this.samplesDir, @@ -23,6 +19,10 @@ class SamplesView extends StatefulWidget { this.fs = const LocalFileSystem(), }); + final Directory samplesDir; + final Catalog catalog; + final FileSystem fs; + @override State createState() => _SamplesViewState(); } @@ -31,9 +31,7 @@ class _SamplesViewState extends State { List _sampleFiles = []; File? _selectedFile; Sample? _selectedSample; - GenUiManager _genUiManager = GenUiManager( - catalog: CoreCatalogItems.asCatalog(), - ); + late GenUiManager _genUiManager; final List _surfaceIds = []; int _currentSurfaceIndex = 0; StreamSubscription? _surfaceSubscription; @@ -42,6 +40,7 @@ class _SamplesViewState extends State { @override void initState() { super.initState(); + _genUiManager = GenUiManager(catalog: widget.catalog); _loadSamples(); _setupSurfaceListener(); } @@ -66,8 +65,6 @@ class _SamplesViewState extends State { } }); } - } else if (update is SurfaceUpdated) { - setState(() {}); } else if (update is SurfaceRemoved) { if (_surfaceIds.contains(update.surfaceId)) { setState(() { @@ -94,10 +91,10 @@ class _SamplesViewState extends State { if (!widget.samplesDir.existsSync()) { return; } - final List files = widget.samplesDir - .listSync() - .whereType() - .where((f) => f.path.endsWith('.sample')) + final List files = await widget.samplesDir + .list() + .where((entity) => entity is File && entity.path.endsWith('.sample')) + .cast() .toList(); setState(() { _sampleFiles = files; @@ -105,7 +102,7 @@ class _SamplesViewState extends State { } Future _selectSample(File file) async { - _messageSubscription?.cancel(); + await _messageSubscription?.cancel(); // Reset surfaces setState(() { _surfaceIds.clear(); @@ -113,7 +110,7 @@ class _SamplesViewState extends State { }); // Re-create GenUiManager to ensure a clean state for the new sample. _genUiManager.dispose(); - _genUiManager = GenUiManager(catalog: CoreCatalogItems.asCatalog()); + _genUiManager = GenUiManager(catalog: widget.catalog); _setupSurfaceListener(); try { @@ -205,7 +202,7 @@ class _SamplesViewState extends State { color: isSelected ? Theme.of( context, - ).primaryColor.withOpacity(0.1) + ).primaryColor.withValues(alpha: 0.1) : null, alignment: Alignment.center, child: Text( diff --git a/examples/catalog_gallery/test/widget_test.dart b/examples/catalog_gallery/test/widget_test.dart index 4ec78e634..2a42c4d69 100644 --- a/examples/catalog_gallery/test/widget_test.dart +++ b/examples/catalog_gallery/test/widget_test.dart @@ -33,14 +33,15 @@ description: A test description await tester.pumpWidget(CatalogGalleryApp(samplesDir: samplesDir, fs: fs)); await tester.pumpAndSettle(); - // Verify that the "Samples" tab is present (since we provided a valid samplesDir) + // Verify that the "Samples" tab is present (since we provided a valid + // samplesDir). expect(find.text('Samples'), findsOneWidget); - // Tap on the Samples tab + // Tap on the Samples tab. await tester.tap(find.text('Samples')); await tester.pumpAndSettle(); - // Verify that the sample file is listed + // Verify that the sample file is listed. expect(find.text('test.sample'), findsOneWidget); }); } From 7c3223015f9cfa66891c632a4b97b00c7732e5dd Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Fri, 21 Nov 2025 14:02:07 -0800 Subject: [PATCH 04/11] Initial port --- .../test/sample_parser_test.dart | 9 +- .../catalog_gallery/test/widget_test.dart | 5 +- .../custom_backend/test/backend_api_test.dart | 2 +- examples/travel_app/.metadata | 12 +- examples/travel_app/macos/.gitignore | 2 - .../macos/Runner.xcodeproj/project.pbxproj | 142 +++++++++--------- .../xcshareddata/xcschemes/Runner.xcscheme | 8 +- .../travel_app/macos/Runner/AppDelegate.swift | 4 - .../macos/Runner/Configs/AppInfo.xcconfig | 6 +- .../macos/Runner/MainFlutterWindow.swift | 4 - .../macos/RunnerTests/RunnerTests.swift | 4 - .../test/widgets/conversation_test.dart | 8 +- .../client/lib/features/ai/ai_provider.dart | 2 +- .../catalog/core_widgets/audio_player.dart | 9 +- .../lib/src/catalog/core_widgets/button.dart | 71 ++++----- .../lib/src/catalog/core_widgets/card.dart | 16 +- .../src/catalog/core_widgets/check_box.dart | 17 +-- .../lib/src/catalog/core_widgets/column.dart | 55 +++---- .../catalog/core_widgets/date_time_input.dart | 9 +- .../lib/src/catalog/core_widgets/divider.dart | 4 +- .../lib/src/catalog/core_widgets/icon.dart | 9 +- .../lib/src/catalog/core_widgets/image.dart | 13 +- .../lib/src/catalog/core_widgets/list.dart | 33 ++-- .../lib/src/catalog/core_widgets/modal.dart | 52 +++---- .../catalog/core_widgets/multiple_choice.dart | 119 +++++++-------- .../lib/src/catalog/core_widgets/row.dart | 33 ++-- .../lib/src/catalog/core_widgets/slider.dart | 15 +- .../lib/src/catalog/core_widgets/tabs.dart | 49 +++--- .../lib/src/catalog/core_widgets/text.dart | 13 +- .../src/catalog/core_widgets/text_field.dart | 34 ++--- .../lib/src/catalog/core_widgets/video.dart | 9 +- .../genui/lib/src/core/genui_manager.dart | 6 +- .../genui/lib/src/core/genui_surface.dart | 8 +- packages/genui/lib/src/core/ui_tools.dart | 41 ++--- .../development_utilities/catalog_view.dart | 2 +- .../facade/direct_call_integration/utils.dart | 5 +- .../genui/lib/src/model/a2ui_message.dart | 31 ++-- .../genui/lib/src/model/a2ui_schemas.dart | 49 +++--- packages/genui/lib/src/model/catalog.dart | 4 +- packages/genui/lib/src/model/ui_models.dart | 29 ++-- .../catalog/core_widgets/button_test.dart | 18 +-- .../test/catalog/core_widgets/card_test.dart | 12 +- .../catalog/core_widgets/check_box_test.dart | 11 +- .../catalog/core_widgets/column_test.dart | 58 ++++--- .../core_widgets/date_time_input_test.dart | 9 +- .../catalog/core_widgets/divider_test.dart | 4 +- .../test/catalog/core_widgets/icon_test.dart | 18 +-- .../test/catalog/core_widgets/list_test.dart | 25 ++- .../test/catalog/core_widgets/modal_test.dart | 45 +++--- .../core_widgets/multiple_choice_test.dart | 29 ++-- .../test/catalog/core_widgets/row_test.dart | 57 +++---- .../catalog/core_widgets/slider_test.dart | 9 +- .../test/catalog/core_widgets/tabs_test.dart | 31 ++-- .../test/catalog/core_widgets/text_test.dart | 2 + .../genui/test/catalog/core_widgets_test.dart | 16 +- packages/genui/test/catalog_test.dart | 9 +- .../genui/test/core/genui_manager_test.dart | 14 +- packages/genui/test/core/ui_tools_test.dart | 18 +-- packages/genui/test/genui_surface_test.dart | 36 ++--- .../genui/test/model/ui_definition_test.dart | 4 +- packages/genui/test/ui_tools_test.dart | 18 +-- .../lib/src/a2ui_agent_connector.dart | 19 ++- .../test/a2ui_agent_connector_test.dart | 17 ++- .../test/a2ui_content_generator_test.dart | 3 +- .../src/firebase_ai_content_generator.dart | 13 +- ...oogle_generative_ai_content_generator.dart | 22 ++- 66 files changed, 687 insertions(+), 773 deletions(-) diff --git a/examples/catalog_gallery/test/sample_parser_test.dart b/examples/catalog_gallery/test/sample_parser_test.dart index da3ea0e9a..b12c3946a 100644 --- a/examples/catalog_gallery/test/sample_parser_test.dart +++ b/examples/catalog_gallery/test/sample_parser_test.dart @@ -12,8 +12,8 @@ void main() { name: Test Sample description: A test description --- -{"surfaceUpdate": {"surfaceId": "default", "components": [{"id": "text1", "component": {"Text": {"text": {"literalString": "Hello"}}}}]}} -{"beginRendering": {"surfaceId": "default", "root": "text1"}} +{"surfaceUpdate": {"surfaceId": "default", "components": [{"id": "text1", "props": {"component": "Text", "text": {"literalString": "Hello"}}}]}} +{"createSurface": {"surfaceId": "default"}} '''; final Sample sample = SampleParser.parseString(sampleContent); @@ -24,16 +24,15 @@ description: A test description final List messages = await sample.messages.toList(); expect(messages.length, 2); expect(messages.first, isA()); - expect(messages.last, isA()); + expect(messages.last, isA()); final update = messages.first as SurfaceUpdate; expect(update.surfaceId, 'default'); expect(update.components.length, 1); expect(update.components.first.type, 'Text'); - final begin = messages.last as BeginRendering; + final begin = messages.last as CreateSurface; expect(begin.surfaceId, 'default'); - expect(begin.root, 'text1'); }); test('SampleParser throws on missing separator', () { diff --git a/examples/catalog_gallery/test/widget_test.dart b/examples/catalog_gallery/test/widget_test.dart index 2a42c4d69..cb5999338 100644 --- a/examples/catalog_gallery/test/widget_test.dart +++ b/examples/catalog_gallery/test/widget_test.dart @@ -25,9 +25,10 @@ void main() { final File sampleFile = samplesDir.childFile('test.sample'); sampleFile.writeAsStringSync(''' name: Test Sample -description: A test description +description: This is a test sample to verify the parser. --- -{"surfaceUpdate": {"surfaceId": "default", "components": [{"id": "text1", "component": {"Text": {"text": {"literalString": "Hello"}}}}]}} +{"surfaceUpdate": {"surfaceId": "default", "components": [{"id": "text1", "component": {"Text": {"text": {"literalString": "Hello World"}}}}]}} +{"beginRendering": {"surfaceId": "default", "root": "text1"}} '''); await tester.pumpWidget(CatalogGalleryApp(samplesDir: samplesDir, fs: fs)); diff --git a/examples/custom_backend/test/backend_api_test.dart b/examples/custom_backend/test/backend_api_test.dart index bee3e8989..2cd37324d 100644 --- a/examples/custom_backend/test/backend_api_test.dart +++ b/examples/custom_backend/test/backend_api_test.dart @@ -33,7 +33,7 @@ void main() { expect(result, isNotNull); expect(result!.messages.length, 2); expect(result.messages[0], isA()); - expect(result.messages[1], isA()); + expect(result.messages[1], isA()); }, retry: 3, timeout: const Timeout(Duration(minutes: 2)), diff --git a/examples/travel_app/.metadata b/examples/travel_app/.metadata index 5492cf186..298107264 100644 --- a/examples/travel_app/.metadata +++ b/examples/travel_app/.metadata @@ -4,7 +4,7 @@ # This file should be version controlled and should not be manually edited. version: - revision: "adc901062556672b4138e18a4dc62a4be8f4b3c2" + revision: "b45fa18946ecc2d9b4009952c636ba7e2ffbb787" channel: "stable" project_type: app @@ -13,11 +13,11 @@ project_type: app migration: platforms: - platform: root - create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 - base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 - - platform: linux - create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 - base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787 + base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787 + - platform: macos + create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787 + base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787 # User provided section diff --git a/examples/travel_app/macos/.gitignore b/examples/travel_app/macos/.gitignore index 8effa017e..746adbb6b 100644 --- a/examples/travel_app/macos/.gitignore +++ b/examples/travel_app/macos/.gitignore @@ -1,7 +1,5 @@ # Flutter-related **/Flutter/ephemeral/ -**/Podfile -**/Podfile.lock **/Pods/ # Xcode-related diff --git a/examples/travel_app/macos/Runner.xcodeproj/project.pbxproj b/examples/travel_app/macos/Runner.xcodeproj/project.pbxproj index d95b8795b..545b04294 100644 --- a/examples/travel_app/macos/Runner.xcodeproj/project.pbxproj +++ b/examples/travel_app/macos/Runner.xcodeproj/project.pbxproj @@ -21,15 +21,14 @@ /* End PBXAggregateTarget section */ /* Begin PBXBuildFile section */ - 14D3C6AA03A2F53BEAA1E023 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = F41263C2CBE0A340BA75F803 /* GoogleService-Info.plist */; }; 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; - 63482C6A6C08BB9F81EB622A /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B5B76D750F31DB184023289C /* Pods_Runner.framework */; }; - 92C895485B5D6EAE40BD982F /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2902378D76640F9873A8E7BA /* Pods_RunnerTests.framework */; }; + 734E853C59BD3038134FBAE7 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5EB774A3ED9541C9E5A7DBA4 /* Pods_Runner.framework */; }; + 950994DFE82E78DF50C13CCF /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 669539D386F9241E5DA53C9A /* Pods_RunnerTests.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -63,12 +62,12 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 2902378D76640F9873A8E7BA /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 246210356D29C5D15130061E /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* genui_client.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = genui_client.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10ED2044A3C60003C045 /* travel_app.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = travel_app.app; sourceTree = BUILT_PRODUCTS_DIR; }; 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; @@ -80,16 +79,15 @@ 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; - 62E0C56DB98B59A3F7E73136 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; - 6AFBC26FB7E6F79D9FF1ABBA /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; - 75E4CFAB98E85B3E246F3185 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; - 774839EDC278525C3B970ACB /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 44A7223F6785CCDBC96CEA35 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 54EF4ABFFBBD4ED2E6E8C8E2 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 5EB774A3ED9541C9E5A7DBA4 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 669539D386F9241E5DA53C9A /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; - 8401E357C5F002F289CB4C77 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 7B79F553FBDBB03252E676B5 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; - 9D1ECB575545C70446AAF02C /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; - B5B76D750F31DB184023289C /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - F41263C2CBE0A340BA75F803 /* GoogleService-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Runner/GoogleService-Info.plist"; sourceTree = ""; }; + B5905576485F075C2C41063C /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + FBE1367E274CF2427DB0841C /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -97,7 +95,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 92C895485B5D6EAE40BD982F /* Pods_RunnerTests.framework in Frameworks */, + 950994DFE82E78DF50C13CCF /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -105,7 +103,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 63482C6A6C08BB9F81EB622A /* Pods_Runner.framework in Frameworks */, + 734E853C59BD3038134FBAE7 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -139,15 +137,14 @@ 331C80D6294CF71000263BE5 /* RunnerTests */, 33CC10EE2044A3C60003C045 /* Products */, D73912EC22F37F3D000D13A0 /* Frameworks */, - EC60867614928B307CB4BFFC /* Pods */, - F41263C2CBE0A340BA75F803 /* GoogleService-Info.plist */, + 9DC24E8C54350212698238F5 /* Pods */, ); sourceTree = ""; }; 33CC10EE2044A3C60003C045 /* Products */ = { isa = PBXGroup; children = ( - 33CC10ED2044A3C60003C045 /* genui_client.app */, + 33CC10ED2044A3C60003C045 /* travel_app.app */, 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, ); name = Products; @@ -188,27 +185,27 @@ path = Runner; sourceTree = ""; }; - D73912EC22F37F3D000D13A0 /* Frameworks */ = { + 9DC24E8C54350212698238F5 /* Pods */ = { isa = PBXGroup; children = ( - B5B76D750F31DB184023289C /* Pods_Runner.framework */, - 2902378D76640F9873A8E7BA /* Pods_RunnerTests.framework */, + FBE1367E274CF2427DB0841C /* Pods-Runner.debug.xcconfig */, + B5905576485F075C2C41063C /* Pods-Runner.release.xcconfig */, + 54EF4ABFFBBD4ED2E6E8C8E2 /* Pods-Runner.profile.xcconfig */, + 44A7223F6785CCDBC96CEA35 /* Pods-RunnerTests.debug.xcconfig */, + 7B79F553FBDBB03252E676B5 /* Pods-RunnerTests.release.xcconfig */, + 246210356D29C5D15130061E /* Pods-RunnerTests.profile.xcconfig */, ); - name = Frameworks; + name = Pods; + path = Pods; sourceTree = ""; }; - EC60867614928B307CB4BFFC /* Pods */ = { + D73912EC22F37F3D000D13A0 /* Frameworks */ = { isa = PBXGroup; children = ( - 75E4CFAB98E85B3E246F3185 /* Pods-Runner.debug.xcconfig */, - 8401E357C5F002F289CB4C77 /* Pods-Runner.release.xcconfig */, - 774839EDC278525C3B970ACB /* Pods-Runner.profile.xcconfig */, - 62E0C56DB98B59A3F7E73136 /* Pods-RunnerTests.debug.xcconfig */, - 6AFBC26FB7E6F79D9FF1ABBA /* Pods-RunnerTests.release.xcconfig */, - 9D1ECB575545C70446AAF02C /* Pods-RunnerTests.profile.xcconfig */, + 5EB774A3ED9541C9E5A7DBA4 /* Pods_Runner.framework */, + 669539D386F9241E5DA53C9A /* Pods_RunnerTests.framework */, ); - name = Pods; - path = Pods; + name = Frameworks; sourceTree = ""; }; /* End PBXGroup section */ @@ -218,7 +215,7 @@ isa = PBXNativeTarget; buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( - 3C4AA3BB3F3CD21560A46BB8 /* [CP] Check Pods Manifest.lock */, + 309CCC2F67C53A49EBA4B5E5 /* [CP] Check Pods Manifest.lock */, 331C80D1294CF70F00263BE5 /* Sources */, 331C80D2294CF70F00263BE5 /* Frameworks */, 331C80D3294CF70F00263BE5 /* Resources */, @@ -237,13 +234,13 @@ isa = PBXNativeTarget; buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( - 703477750A6EA69C0FBAFEB2 /* [CP] Check Pods Manifest.lock */, + AA7676B8E6E029E44ABAE708 /* [CP] Check Pods Manifest.lock */, 33CC10E92044A3C60003C045 /* Sources */, 33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, - 997FC1770EA917D1C5764C28 /* [CP] Embed Pods Frameworks */, + 5C48C64FE089AB0E5D33E410 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -252,7 +249,7 @@ ); name = Runner; productName = Runner; - productReference = 33CC10ED2044A3C60003C045 /* genui_client.app */; + productReference = 33CC10ED2044A3C60003C045 /* travel_app.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ @@ -320,13 +317,34 @@ files = ( 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, - 14D3C6AA03A2F53BEAA1E023 /* GoogleService-Info.plist in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 309CCC2F67C53A49EBA4B5E5 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 3399D490228B24CF009A79C7 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -365,29 +383,24 @@ shellPath = /bin/sh; shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; }; - 3C4AA3BB3F3CD21560A46BB8 /* [CP] Check Pods Manifest.lock */ = { + 5C48C64FE089AB0E5D33E410 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; + name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; - 703477750A6EA69C0FBAFEB2 /* [CP] Check Pods Manifest.lock */ = { + AA7676B8E6E029E44ABAE708 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -409,23 +422,6 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - 997FC1770EA917D1C5764C28 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -477,46 +473,46 @@ /* Begin XCBuildConfiguration section */ 331C80DB294CF71000263BE5 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 62E0C56DB98B59A3F7E73136 /* Pods-RunnerTests.debug.xcconfig */; + baseConfigurationReference = 44A7223F6785CCDBC96CEA35 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.genui.genuiClient.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.genui.travelApp.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/genui_client.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/genui_client"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/travel_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/travel_app"; }; name = Debug; }; 331C80DC294CF71000263BE5 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 6AFBC26FB7E6F79D9FF1ABBA /* Pods-RunnerTests.release.xcconfig */; + baseConfigurationReference = 7B79F553FBDBB03252E676B5 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.genui.genuiClient.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.genui.travelApp.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/genui_client.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/genui_client"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/travel_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/travel_app"; }; name = Release; }; 331C80DD294CF71000263BE5 /* Profile */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 9D1ECB575545C70446AAF02C /* Pods-RunnerTests.profile.xcconfig */; + baseConfigurationReference = 246210356D29C5D15130061E /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.genui.genuiClient.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.genui.travelApp.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/genui_client.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/genui_client"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/travel_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/travel_app"; }; name = Profile; }; diff --git a/examples/travel_app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/examples/travel_app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index b90d0dbaa..af141e5f7 100644 --- a/examples/travel_app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/examples/travel_app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -15,7 +15,7 @@ @@ -31,7 +31,7 @@ @@ -66,7 +66,7 @@ @@ -83,7 +83,7 @@ diff --git a/examples/travel_app/macos/Runner/AppDelegate.swift b/examples/travel_app/macos/Runner/AppDelegate.swift index 43bd41192..b3c176141 100644 --- a/examples/travel_app/macos/Runner/AppDelegate.swift +++ b/examples/travel_app/macos/Runner/AppDelegate.swift @@ -1,7 +1,3 @@ -// 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 Cocoa import FlutterMacOS diff --git a/examples/travel_app/macos/Runner/Configs/AppInfo.xcconfig b/examples/travel_app/macos/Runner/Configs/AppInfo.xcconfig index c3c2aacec..69237d8c7 100644 --- a/examples/travel_app/macos/Runner/Configs/AppInfo.xcconfig +++ b/examples/travel_app/macos/Runner/Configs/AppInfo.xcconfig @@ -5,10 +5,10 @@ // 'flutter create' template. // The application's name. By default this is also the title of the Flutter window. -PRODUCT_NAME = genui_client +PRODUCT_NAME = travel_app // The application's bundle identifier -PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.genui.genuiClient +PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.genui.travelApp // The copyright displayed in application information -PRODUCT_COPYRIGHT = Copyright © 2025 dev.flutter.genui. +PRODUCT_COPYRIGHT = Copyright © 2025 dev.flutter.genui. All rights reserved. diff --git a/examples/travel_app/macos/Runner/MainFlutterWindow.swift b/examples/travel_app/macos/Runner/MainFlutterWindow.swift index 79861d1c4..3cc05eb23 100644 --- a/examples/travel_app/macos/Runner/MainFlutterWindow.swift +++ b/examples/travel_app/macos/Runner/MainFlutterWindow.swift @@ -1,7 +1,3 @@ -// 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 Cocoa import FlutterMacOS diff --git a/examples/travel_app/macos/RunnerTests/RunnerTests.swift b/examples/travel_app/macos/RunnerTests/RunnerTests.swift index 8b03e329d..61f3bd1fc 100644 --- a/examples/travel_app/macos/RunnerTests/RunnerTests.swift +++ b/examples/travel_app/macos/RunnerTests/RunnerTests.swift @@ -1,7 +1,3 @@ -// 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 Cocoa import FlutterMacOS import XCTest diff --git a/examples/travel_app/test/widgets/conversation_test.dart b/examples/travel_app/test/widgets/conversation_test.dart index 6ca04aca7..04bf7679a 100644 --- a/examples/travel_app/test/widgets/conversation_test.dart +++ b/examples/travel_app/test/widgets/conversation_test.dart @@ -27,7 +27,7 @@ void main() { final components = [ const Component( id: 'r1', - componentProperties: { + props: { 'Text': { 'text': {'literalString': 'Hi there!'}, }, @@ -38,7 +38,7 @@ void main() { SurfaceUpdate(surfaceId: surfaceId, components: components), ); manager.handleMessage( - const BeginRendering(surfaceId: surfaceId, root: 'r1'), + const CreateSurface(surfaceId: surfaceId), ); await tester.pumpWidget( @@ -79,7 +79,7 @@ void main() { final components = [ const Component( id: 'root', - componentProperties: { + props: { 'Text': { 'text': {'literalString': 'UI Content'}, }, @@ -90,7 +90,7 @@ void main() { SurfaceUpdate(surfaceId: surfaceId, components: components), ); manager.handleMessage( - const BeginRendering(surfaceId: surfaceId, root: 'root'), + const CreateSurface(surfaceId: surfaceId), ); await tester.pumpWidget( MaterialApp( diff --git a/examples/verdure/client/lib/features/ai/ai_provider.dart b/examples/verdure/client/lib/features/ai/ai_provider.dart index 067e16898..16a7970ee 100644 --- a/examples/verdure/client/lib/features/ai/ai_provider.dart +++ b/examples/verdure/client/lib/features/ai/ai_provider.dart @@ -84,7 +84,7 @@ class Ai extends _$Ai { contentGenerator.a2uiMessageStream.listen((message) { switch (message) { - case BeginRendering(): + case CreateSurface(): surfaceUpdateController.add(message.surfaceId); case SurfaceUpdate(): case DataModelUpdate(): diff --git a/packages/genui/lib/src/catalog/core_widgets/audio_player.dart b/packages/genui/lib/src/catalog/core_widgets/audio_player.dart index 9d58af8ae..24ff9b591 100644 --- a/packages/genui/lib/src/catalog/core_widgets/audio_player.dart +++ b/packages/genui/lib/src/catalog/core_widgets/audio_player.dart @@ -39,11 +39,10 @@ final audioPlayer = CatalogItem( [ { "id": "root", - "component": { - "AudioPlayer": { - "url": { - "literalString": "https://example.com/audio.mp3" - } + "props": { + "component": "AudioPlayer", + "url": { + "literalString": "https://example.com/audio.mp3" } } } diff --git a/packages/genui/lib/src/catalog/core_widgets/button.dart b/packages/genui/lib/src/catalog/core_widgets/button.dart index 6de2f2857..75868691f 100644 --- a/packages/genui/lib/src/catalog/core_widgets/button.dart +++ b/packages/genui/lib/src/catalog/core_widgets/button.dart @@ -109,22 +109,20 @@ final button = CatalogItem( [ { "id": "root", - "component": { - "Button": { - "child": "text", - "action": { - "name": "button_pressed" - } + "props": { + "component": "Button", + "child": "text", + "action": { + "name": "button_pressed" } } }, { "id": "text", - "component": { - "Text": { - "text": { - "literalString": "Hello World" - } + "props": { + "component": "Text", + "text": { + "literalString": "Hello World" } } } @@ -134,54 +132,49 @@ final button = CatalogItem( [ { "id": "root", - "component": { - "Column": { - "children": { - "explicitList": ["primaryButton", "secondaryButton"] - } + "props": { + "component": "Column", + "children": { + "explicitList": ["primaryButton", "secondaryButton"] } } }, { "id": "primaryButton", - "component": { - "Button": { - "child": "primaryText", - "primary": true, - "action": { - "name": "primary_pressed" - } + "props": { + "component": "Button", + "child": "primaryText", + "primary": true, + "action": { + "name": "primary_pressed" } } }, { "id": "secondaryButton", - "component": { - "Button": { - "child": "secondaryText", - "action": { - "name": "secondary_pressed" - } + "props": { + "component": "Button", + "child": "secondaryText", + "action": { + "name": "secondary_pressed" } } }, { "id": "primaryText", - "component": { - "Text": { - "text": { - "literalString": "Primary Button" - } + "props": { + "component": "Text", + "text": { + "literalString": "Primary Button" } } }, { "id": "secondaryText", - "component": { - "Text": { - "text": { - "literalString": "Secondary Button" - } + "props": { + "component": "Text", + "text": { + "literalString": "Secondary Button" } } } diff --git a/packages/genui/lib/src/catalog/core_widgets/card.dart b/packages/genui/lib/src/catalog/core_widgets/card.dart index 9320d9b21..9b5b16b21 100644 --- a/packages/genui/lib/src/catalog/core_widgets/card.dart +++ b/packages/genui/lib/src/catalog/core_widgets/card.dart @@ -48,19 +48,17 @@ final card = CatalogItem( [ { "id": "root", - "component": { - "Card": { - "child": "text" - } + "props": { + "component": "Card", + "child": "text" } }, { "id": "text", - "component": { - "Text": { - "text": { - "literalString": "This is a card." - } + "props": { + "component": "Text", + "text": { + "literalString": "This is a card." } } } diff --git a/packages/genui/lib/src/catalog/core_widgets/check_box.dart b/packages/genui/lib/src/catalog/core_widgets/check_box.dart index 17e896e90..f874aae38 100644 --- a/packages/genui/lib/src/catalog/core_widgets/check_box.dart +++ b/packages/genui/lib/src/catalog/core_widgets/check_box.dart @@ -76,15 +76,14 @@ final checkBox = CatalogItem( [ { "id": "root", - "component": { - "CheckBox": { - "label": { - "literalString": "Check me" - }, - "value": { - "path": "/myValue", - "literalBoolean": true - } + "props": { + "component": "CheckBox", + "label": { + "literalString": "Check me" + }, + "value": { + "path": "/myValue", + "literalBoolean": true } } } diff --git a/packages/genui/lib/src/catalog/core_widgets/column.dart b/packages/genui/lib/src/catalog/core_widgets/column.dart index 47922a387..475dae142 100644 --- a/packages/genui/lib/src/catalog/core_widgets/column.dart +++ b/packages/genui/lib/src/catalog/core_widgets/column.dart @@ -155,56 +155,51 @@ final column = CatalogItem( [ { "id": "root", - "component": { - "Column": { - "children": { - "explicitList": [ - "advice_text", - "advice_options", - "submit_button" - ] - } + "props": { + "component": "Column", + "children": { + "explicitList": [ + "advice_text", + "advice_options", + "submit_button" + ] } } }, { "id": "advice_text", - "component": { - "Text": { - "text": { - "literalString": "What kind of advice are you looking for?" - } + "props": { + "component": "Text", + "text": { + "literalString": "What kind of advice are you looking for?" } } }, { "id": "advice_options", - "component": { - "Text": { - "text": { - "literalString": "Some advice options." - } + "props": { + "component": "Text", + "text": { + "literalString": "Some advice options." } } }, { "id": "submit_button", - "component": { - "Button": { - "child": "submit_button_text", - "action": { - "name": "submit" - } + "props": { + "component": "Button", + "child": "submit_button_text", + "action": { + "name": "submit" } } }, { "id": "submit_button_text", - "component": { - "Text": { - "text": { - "literalString": "Submit" - } + "props": { + "component": "Text", + "text": { + "literalString": "Submit" } } } diff --git a/packages/genui/lib/src/catalog/core_widgets/date_time_input.dart b/packages/genui/lib/src/catalog/core_widgets/date_time_input.dart index 56ab1f4f3..d51447de5 100644 --- a/packages/genui/lib/src/catalog/core_widgets/date_time_input.dart +++ b/packages/genui/lib/src/catalog/core_widgets/date_time_input.dart @@ -112,11 +112,10 @@ final dateTimeInput = CatalogItem( [ { "id": "root", - "component": { - "DateTimeInput": { - "value": { - "path": "/myDateTime" - } + "props": { + "component": "DateTimeInput", + "value": { + "path": "/myDateTime" } } } diff --git a/packages/genui/lib/src/catalog/core_widgets/divider.dart b/packages/genui/lib/src/catalog/core_widgets/divider.dart index 02e69426d..f9d009c3c 100644 --- a/packages/genui/lib/src/catalog/core_widgets/divider.dart +++ b/packages/genui/lib/src/catalog/core_widgets/divider.dart @@ -44,8 +44,8 @@ final divider = CatalogItem( [ { "id": "root", - "component": { - "Divider": {} + "props": { + "component": "Divider" } } ] diff --git a/packages/genui/lib/src/catalog/core_widgets/icon.dart b/packages/genui/lib/src/catalog/core_widgets/icon.dart index 3d882ddaf..05026b20e 100644 --- a/packages/genui/lib/src/catalog/core_widgets/icon.dart +++ b/packages/genui/lib/src/catalog/core_widgets/icon.dart @@ -139,11 +139,10 @@ final icon = CatalogItem( [ { "id": "root", - "component": { - "Icon": { - "name": { - "literalString": "add" - } + "props": { + "component": "Icon", + "name": { + "literalString": "add" } } } diff --git a/packages/genui/lib/src/catalog/core_widgets/image.dart b/packages/genui/lib/src/catalog/core_widgets/image.dart index 079814bbd..f8894dc86 100644 --- a/packages/genui/lib/src/catalog/core_widgets/image.dart +++ b/packages/genui/lib/src/catalog/core_widgets/image.dart @@ -72,13 +72,12 @@ CatalogItem _imageCatalogItem({ [ { "id": "root", - "component": { - "Image": { - "url": { - "literalString": "https://storage.googleapis.com/cms-storage-bucket/lockup_flutter_horizontal.c823e53b3a1a7b0d36a9.png" - }, - "usageHint": "mediumFeature" - } + "props": { + "component": "Image", + "url": { + "literalString": "https://storage.googleapis.com/cms-storage-bucket/lockup_flutter_horizontal.c823e53b3a1a7b0d36a9.png" + }, + "usageHint": "mediumFeature" } } ] diff --git a/packages/genui/lib/src/catalog/core_widgets/list.dart b/packages/genui/lib/src/catalog/core_widgets/list.dart index 42e9c08e8..c4cdfe234 100644 --- a/packages/genui/lib/src/catalog/core_widgets/list.dart +++ b/packages/genui/lib/src/catalog/core_widgets/list.dart @@ -90,34 +90,31 @@ final list = CatalogItem( [ { "id": "root", - "component": { - "List": { - "children": { - "explicitList": [ - "text1", - "text2" - ] - } + "props": { + "component": "List", + "children": { + "explicitList": [ + "text1", + "text2" + ] } } }, { "id": "text1", - "component": { - "Text": { - "text": { - "literalString": "First" - } + "props": { + "component": "Text", + "text": { + "literalString": "First" } } }, { "id": "text2", - "component": { - "Text": { - "text": { - "literalString": "Second" - } + "props": { + "component": "Text", + "text": { + "literalString": "Second" } } } diff --git a/packages/genui/lib/src/catalog/core_widgets/modal.dart b/packages/genui/lib/src/catalog/core_widgets/modal.dart index a0023a75c..1e7fb7aa6 100644 --- a/packages/genui/lib/src/catalog/core_widgets/modal.dart +++ b/packages/genui/lib/src/catalog/core_widgets/modal.dart @@ -59,49 +59,45 @@ final modal = CatalogItem( [ { "id": "root", - "component": { - "Modal": { - "entryPointChild": "button", - "contentChild": "text" - } + "props": { + "component": "Modal", + "entryPointChild": "button", + "contentChild": "text" } }, { "id": "button", - "component": { - "Button": { - "child": "button_text", - "action": { - "name": "showModal", - "context": [ - { - "key": "modalId", - "value": { - "literalString": "root" - } + "props": { + "component": "Button", + "child": "button_text", + "action": { + "name": "showModal", + "context": [ + { + "key": "modalId", + "value": { + "literalString": "root" } - ] - } + } + ] } } }, { "id": "button_text", - "component": { - "Text": { - "text": { - "literalString": "Open Modal" - } + "props": { + "component": "Text", + "text": { + "literalString": "Open Modal" } } }, { "id": "text", - "component": { - "Text": { - "text": { - "literalString": "This is a modal." - } + "props": { + "component": "Text", + "text": { + "literalString": "This is a modal." } } } diff --git a/packages/genui/lib/src/catalog/core_widgets/multiple_choice.dart b/packages/genui/lib/src/catalog/core_widgets/multiple_choice.dart index 1e88f7053..e565faf94 100644 --- a/packages/genui/lib/src/catalog/core_widgets/multiple_choice.dart +++ b/packages/genui/lib/src/catalog/core_widgets/multiple_choice.dart @@ -148,92 +148,87 @@ final multipleChoice = CatalogItem( [ { "id": "root", - "component": { - "Column": { - "children": { - "explicitList": [ - "heading1", - "singleChoice", - "heading2", - "multiChoice" - ] - } + "props": { + "component": "Column", + "children": { + "explicitList": [ + "heading1", + "singleChoice", + "heading2", + "multiChoice" + ] } } }, { "id": "heading1", - "component": { - "Text": { - "text": { - "literalString": "Single Selection (maxAllowedSelections: 1)" - } + "props": { + "component": "Text", + "text": { + "literalString": "Single Selection (maxAllowedSelections: 1)" } } }, { "id": "singleChoice", - "component": { - "MultipleChoice": { - "selections": { - "path": "/singleSelection" + "props": { + "component": "MultipleChoice", + "selections": { + "path": "/singleSelection" + }, + "maxAllowedSelections": 1, + "options": [ + { + "label": { + "literalString": "Option A" + }, + "value": "A" }, - "maxAllowedSelections": 1, - "options": [ - { - "label": { - "literalString": "Option A" - }, - "value": "A" + { + "label": { + "literalString": "Option B" }, - { - "label": { - "literalString": "Option B" - }, - "value": "B" - } - ] - } + "value": "B" + } + ] } }, { "id": "heading2", - "component": { - "Text": { - "text": { - "literalString": "Multiple Selections (unlimited)" - } + "props": { + "component": "Text", + "text": { + "literalString": "Multiple Selections (unlimited)" } } }, { "id": "multiChoice", - "component": { - "MultipleChoice": { - "selections": { - "path": "/multiSelection" + "props": { + "component": "MultipleChoice", + "selections": { + "path": "/multiSelection" + }, + "options": [ + { + "label": { + "literalString": "Option X" + }, + "value": "X" }, - "options": [ - { - "label": { - "literalString": "Option X" - }, - "value": "X" + { + "label": { + "literalString": "Option Y" }, - { - "label": { - "literalString": "Option Y" - }, - "value": "Y" + "value": "Y" + }, + { + "label": { + "literalString": "Option Z" }, - { - "label": { - "literalString": "Option Z" - }, - "value": "Z" - } - ] - } + "value": "Z" + } + ] } } ] diff --git a/packages/genui/lib/src/catalog/core_widgets/row.dart b/packages/genui/lib/src/catalog/core_widgets/row.dart index b9beb9b30..cd8ba003a 100644 --- a/packages/genui/lib/src/catalog/core_widgets/row.dart +++ b/packages/genui/lib/src/catalog/core_widgets/row.dart @@ -155,34 +155,31 @@ final row = CatalogItem( [ { "id": "root", - "component": { - "Row": { - "children": { - "explicitList": [ - "text1", - "text2" - ] - } + "props": { + "component": "Row", + "children": { + "explicitList": [ + "text1", + "text2" + ] } } }, { "id": "text1", - "component": { - "Text": { - "text": { - "literalString": "First" - } + "props": { + "component": "Text", + "text": { + "literalString": "First" } } }, { "id": "text2", - "component": { - "Text": { - "text": { - "literalString": "Second" - } + "props": { + "component": "Text", + "text": { + "literalString": "Second" } } } diff --git a/packages/genui/lib/src/catalog/core_widgets/slider.dart b/packages/genui/lib/src/catalog/core_widgets/slider.dart index c4a1f5c2a..47b2f9e71 100644 --- a/packages/genui/lib/src/catalog/core_widgets/slider.dart +++ b/packages/genui/lib/src/catalog/core_widgets/slider.dart @@ -93,14 +93,13 @@ final slider = CatalogItem( [ { "id": "root", - "component": { - "Slider": { - "minValue": 0, - "maxValue": 10, - "value": { - "path": "/myValue", - "literalNumber": 5 - } + "props": { + "component": "Slider", + "minValue": 0, + "maxValue": 10, + "value": { + "path": "/myValue", + "literalNumber": 5 } } } diff --git a/packages/genui/lib/src/catalog/core_widgets/tabs.dart b/packages/genui/lib/src/catalog/core_widgets/tabs.dart index 57294d40d..19d56261d 100644 --- a/packages/genui/lib/src/catalog/core_widgets/tabs.dart +++ b/packages/genui/lib/src/catalog/core_widgets/tabs.dart @@ -88,42 +88,39 @@ final tabs = CatalogItem( [ { "id": "root", - "component": { - "Tabs": { - "tabItems": [ - { - "title": { - "literalString": "Overview" - }, - "child": "text1" + "props": { + "component": "Tabs", + "tabItems": [ + { + "title": { + "literalString": "Overview" }, - { - "title": { - "literalString": "Details" - }, - "child": "text2" - } - ] - } + "child": "text1" + }, + { + "title": { + "literalString": "Details" + }, + "child": "text2" + } + ] } }, { "id": "text1", - "component": { - "Text": { - "text": { - "literalString": "This is a short summary of the item." - } + "props": { + "component": "Text", + "text": { + "literalString": "This is a short summary of the item." } } }, { "id": "text2", - "component": { - "Text": { - "text": { - "literalString": "This is a much longer, more detailed description of the item, providing in-depth information and context. It can span multiple lines and include rich formatting if needed." - } + "props": { + "component": "Text", + "text": { + "literalString": "This is a much longer, more detailed description of the item, providing in-depth information and context. It can span multiple lines and include rich formatting if needed." } } } diff --git a/packages/genui/lib/src/catalog/core_widgets/text.dart b/packages/genui/lib/src/catalog/core_widgets/text.dart index 6709fa02b..dc01dcab6 100644 --- a/packages/genui/lib/src/catalog/core_widgets/text.dart +++ b/packages/genui/lib/src/catalog/core_widgets/text.dart @@ -50,13 +50,12 @@ final text = CatalogItem( [ { "id": "root", - "component": { - "Text": { - "text": { - "literalString": "Hello World" - }, - "usageHint": "h1" - } + "props": { + "component": "Text", + "text": { + "literalString": "Hello World" + }, + "usageHint": "h1" } } ] diff --git a/packages/genui/lib/src/catalog/core_widgets/text_field.dart b/packages/genui/lib/src/catalog/core_widgets/text_field.dart index f92bd2b39..6722a53b6 100644 --- a/packages/genui/lib/src/catalog/core_widgets/text_field.dart +++ b/packages/genui/lib/src/catalog/core_widgets/text_field.dart @@ -133,14 +133,13 @@ final textField = CatalogItem( [ { "id": "root", - "component": { - "TextField": { - "text": { - "literalString": "Hello World" - }, - "label": { - "literalString": "Greeting" - } + "props": { + "component": "TextField", + "text": { + "literalString": "Hello World" + }, + "label": { + "literalString": "Greeting" } } } @@ -150,16 +149,15 @@ final textField = CatalogItem( [ { "id": "root", - "component": { - "TextField": { - "text": { - "literalString": "password123" - }, - "label": { - "literalString": "Password" - }, - "textFieldType": "obscured" - } + "props": { + "component": "TextField", + "text": { + "literalString": "password123" + }, + "label": { + "literalString": "Password" + }, + "textFieldType": "obscured" } } ] diff --git a/packages/genui/lib/src/catalog/core_widgets/video.dart b/packages/genui/lib/src/catalog/core_widgets/video.dart index b75a4c5c8..ad89063cb 100644 --- a/packages/genui/lib/src/catalog/core_widgets/video.dart +++ b/packages/genui/lib/src/catalog/core_widgets/video.dart @@ -39,11 +39,10 @@ final video = CatalogItem( [ { "id": "root", - "component": { - "Video": { - "url": { - "literalString": "https://example.com/video.mp4" - } + "props": { + "component": "Video", + "url": { + "literalString": "https://example.com/video.mp4" } } } diff --git a/packages/genui/lib/src/core/genui_manager.dart b/packages/genui/lib/src/core/genui_manager.dart index daabf84b9..db08fa50e 100644 --- a/packages/genui/lib/src/core/genui_manager.dart +++ b/packages/genui/lib/src/core/genui_manager.dart @@ -183,7 +183,7 @@ class GenUiManager implements GenUiHost { genUiLogger.info('Updating surface $surfaceId'); _surfaceUpdates.add(SurfaceUpdated(surfaceId, uiDefinition)); } - case BeginRendering(): + case CreateSurface(): dataModelForSurface(message.surfaceId); final ValueNotifier notifier = getSurfaceNotifier( message.surfaceId, @@ -191,10 +191,10 @@ class GenUiManager implements GenUiHost { final UiDefinition uiDefinition = notifier.value ?? UiDefinition(surfaceId: message.surfaceId); final UiDefinition newUiDefinition = uiDefinition.copyWith( - rootComponentId: message.root, + theme: message.theme, ); notifier.value = newUiDefinition; - genUiLogger.info('Started rendering ${message.surfaceId}'); + genUiLogger.info('Created surface ${message.surfaceId}'); _surfaceUpdates.add(SurfaceUpdated(message.surfaceId, newUiDefinition)); case DataModelUpdate(): final String path = message.path ?? '/'; diff --git a/packages/genui/lib/src/core/genui_surface.dart b/packages/genui/lib/src/core/genui_surface.dart index 10984c726..9b1dc00ac 100644 --- a/packages/genui/lib/src/core/genui_surface.dart +++ b/packages/genui/lib/src/core/genui_surface.dart @@ -53,8 +53,8 @@ class _GenUiSurfaceState extends State { return widget.defaultBuilder?.call(context) ?? const SizedBox.shrink(); } - final String? rootId = definition.rootComponentId; - if (rootId == null || definition.components.isEmpty) { + final String rootId = definition.rootComponentId ?? 'root'; + if (definition.components.isEmpty) { genUiLogger.warning('Surface ${widget.surfaceId} has no widgets.'); return const SizedBox.shrink(); } @@ -82,7 +82,7 @@ class _GenUiSurfaceState extends State { return Placeholder(child: Text('Widget with id: $widgetId not found.')); } - final JsonMap widgetData = data.componentProperties; + final JsonMap widgetData = data.props; genUiLogger.finest('Building widget $widgetId'); return widget.host.catalog.buildWidget( CatalogItemContext( @@ -110,7 +110,7 @@ class _GenUiSurfaceState extends State { final Component? modalComponent = definition.components[modalId]; if (modalComponent == null) return; final contentChildId = - (modalComponent.componentProperties['Modal'] as Map)['contentChild'] + (modalComponent.props['Modal'] as Map)['contentChild'] as String; showModalBottomSheet( context: context, diff --git a/packages/genui/lib/src/core/ui_tools.dart b/packages/genui/lib/src/core/ui_tools.dart index 69740f365..afa52eb7e 100644 --- a/packages/genui/lib/src/core/ui_tools.dart +++ b/packages/genui/lib/src/core/ui_tools.dart @@ -41,7 +41,7 @@ class SurfaceUpdateTool extends AiTool { final component = e as JsonMap; return Component( id: component['id'] as String, - componentProperties: component['component'] as JsonMap, + props: component['props'] as JsonMap, ); }).toList(); handleMessage(SurfaceUpdate(surfaceId: surfaceId, components: components)); @@ -83,43 +83,28 @@ class DeleteSurfaceTool extends AiTool { } } -/// An [AiTool] for signaling the client to begin rendering. +/// An [AiTool] for signaling the client to create a surface. /// -/// This tool allows the AI to specify the root component of a UI surface. -class BeginRenderingTool extends AiTool { - /// Creates a [BeginRenderingTool]. - BeginRenderingTool({required this.handleMessage}) +/// This tool allows the AI to initialize a UI surface. +class CreateSurfaceTool extends AiTool { + /// Creates a [CreateSurfaceTool]. + CreateSurfaceTool({required this.handleMessage}) : super( - name: 'beginRendering', - description: - 'Signals the client to begin rendering a surface with a ' - 'root component.', - parameters: S.object( - properties: { - surfaceIdKey: S.string( - description: - 'The unique identifier for the UI surface to render.', - ), - 'root': S.string( - description: - 'The ID of the root widget. This ID must correspond to ' - 'the ID of one of the widgets in the `components` list.', - ), - }, - required: [surfaceIdKey, 'root'], - ), + name: 'createSurface', + description: 'Signals the client to create a surface.', + parameters: A2uiSchemas.createSurfaceSchema(), ); - /// The callback to invoke when signaling to begin rendering. + /// The callback to invoke when signaling to create a surface. final void Function(A2uiMessage message) handleMessage; @override Future invoke(JsonMap args) async { final surfaceId = args[surfaceIdKey] as String; - final root = args['root'] as String; - handleMessage(BeginRendering(surfaceId: surfaceId, root: root)); + final theme = args['theme'] as JsonMap?; + handleMessage(CreateSurface(surfaceId: surfaceId, theme: theme)); return { - 'status': 'Surface $surfaceId rendered and waiting for user input.', + 'status': 'Surface $surfaceId created.', }; } } diff --git a/packages/genui/lib/src/development_utilities/catalog_view.dart b/packages/genui/lib/src/development_utilities/catalog_view.dart index 8ad9d9508..edc3a2465 100644 --- a/packages/genui/lib/src/development_utilities/catalog_view.dart +++ b/packages/genui/lib/src/development_utilities/catalog_view.dart @@ -88,7 +88,7 @@ class _DebugCatalogViewState extends State { SurfaceUpdate(surfaceId: surfaceId, components: components), ); _genUi.handleMessage( - BeginRendering(surfaceId: surfaceId, root: rootComponent.id), + CreateSurface(surfaceId: surfaceId), ); surfaceIds.add(surfaceId); } diff --git a/packages/genui/lib/src/facade/direct_call_integration/utils.dart b/packages/genui/lib/src/facade/direct_call_integration/utils.dart index 5a5f979c9..5f449b35a 100644 --- a/packages/genui/lib/src/facade/direct_call_integration/utils.dart +++ b/packages/genui/lib/src/facade/direct_call_integration/utils.dart @@ -50,13 +50,12 @@ ParsedToolCall parseToolCall(ToolCall toolCall, String toolName) { final surfaceId = (toolCall.args as JsonMap)[surfaceIdKey] as String; - final beginRenderingMessage = BeginRendering( + final createSurfaceMessage = CreateSurface( surfaceId: surfaceId, - root: 'root', ); return ParsedToolCall( - messages: [surfaceUpdateMessage, beginRenderingMessage], + messages: [surfaceUpdateMessage, createSurfaceMessage], surfaceId: surfaceId, ); } diff --git a/packages/genui/lib/src/model/a2ui_message.dart b/packages/genui/lib/src/model/a2ui_message.dart index a9208bbb7..df558cb9e 100644 --- a/packages/genui/lib/src/model/a2ui_message.dart +++ b/packages/genui/lib/src/model/a2ui_message.dart @@ -23,8 +23,8 @@ sealed class A2uiMessage { if (json.containsKey('dataModelUpdate')) { return DataModelUpdate.fromJson(json['dataModelUpdate'] as JsonMap); } - if (json.containsKey('beginRendering')) { - return BeginRendering.fromJson(json['beginRendering'] as JsonMap); + if (json.containsKey('createSurface')) { + return CreateSurface.fromJson(json['createSurface'] as JsonMap); } if (json.containsKey('deleteSurface')) { return SurfaceDeletion.fromJson(json['deleteSurface'] as JsonMap); @@ -41,7 +41,7 @@ sealed class A2uiMessage { properties: { 'surfaceUpdate': A2uiSchemas.surfaceUpdateSchema(catalog), 'dataModelUpdate': A2uiSchemas.dataModelUpdateSchema(), - 'beginRendering': A2uiSchemas.beginRenderingSchema(), + 'createSurface': A2uiSchemas.createSurfaceSchema(), 'deleteSurface': A2uiSchemas.surfaceDeletionSchema(), }, ); @@ -107,31 +107,26 @@ final class DataModelUpdate extends A2uiMessage { } /// An A2UI message that signals the client to begin rendering. -final class BeginRendering extends A2uiMessage { - /// Creates a [BeginRendering] message. - const BeginRendering({ +final class CreateSurface extends A2uiMessage { + /// Creates a [CreateSurface] message. + const CreateSurface({ required this.surfaceId, - required this.root, - this.styles, + this.theme, }); - /// Creates a [BeginRendering] message from a JSON map. - factory BeginRendering.fromJson(JsonMap json) { - return BeginRendering( + /// Creates a [CreateSurface] message from a JSON map. + factory CreateSurface.fromJson(JsonMap json) { + return CreateSurface( surfaceId: json[surfaceIdKey] as String, - root: json['root'] as String, - styles: json['styles'] as JsonMap?, + theme: json['theme'] as JsonMap?, ); } /// The ID of the surface that this message applies to. final String surfaceId; - /// The ID of the root component. - final String root; - - /// The styles to apply to the UI. - final JsonMap? styles; + /// The theme to apply to the UI. + final JsonMap? theme; } /// An A2UI message that deletes a surface. diff --git a/packages/genui/lib/src/model/a2ui_schemas.dart b/packages/genui/lib/src/model/a2ui_schemas.dart index f5571d954..c95c65b54 100644 --- a/packages/genui/lib/src/model/a2ui_schemas.dart +++ b/packages/genui/lib/src/model/a2ui_schemas.dart @@ -131,20 +131,13 @@ class A2uiSchemas { }, ); - /// Schema for a beginRendering message, which provides the root widget ID for - /// the given surface so that the surface can be rendered. - static Schema beginRenderingSchema() => S.object( + /// Schema for a createSurface message, which initializes a surface. + static Schema createSurfaceSchema() => S.object( properties: { surfaceIdKey: S.string( - description: 'The surface ID of the surface to render.', + description: 'The surface ID of the surface to create.', ), - 'root': S.string( - description: - 'The root widget ID for the surface. ' - 'All components must be descendents of this root in order to be ' - 'displayed.', - ), - 'styles': S.object( + 'theme': S.object( properties: { 'font': S.string(description: 'The base font for this surface'), 'primaryColor': S.string( @@ -153,7 +146,7 @@ class A2uiSchemas { }, ), }, - required: [surfaceIdKey, 'root'], + required: [surfaceIdKey], ); /// Schema for a `deleteSurface` message which will delete the given surface. @@ -199,21 +192,33 @@ class A2uiSchemas { description: 'Optional layout weight for use in Row/Column children.', ), - 'component': S.object( + 'props': S.object( description: - '''A wrapper object that MUST contain exactly one key, which is the name of the component type (e.g., 'Text'). The value is an object containing the properties for that specific component.''', + "A wrapper object that MUST contain a 'component' key " + "specifying the component type (e.g., 'Text'), and other " + 'properties for that specific component.', properties: { - for (var entry - in ((catalog.definition as ObjectSchema) - .properties!['components']! - as ObjectSchema) - .properties! - .entries) - entry.key: entry.value, + 'component': S.string( + description: 'The type of the component.', + enumValues: + ((catalog.definition as ObjectSchema) + .properties!['components']! + as ObjectSchema) + .properties! + .keys + .toList(), + ), + // We can't easily enumerate all possible properties here + // without a more complex schema structure that uses 'oneOf' or + // 'discriminator', but for now we'll allow additional + // properties and rely on the AI to follow the catalog + // definition. Ideally, we would merge the component schemas + // here. }, + additionalProperties: true, ), }, - required: ['id', 'component'], + required: ['id', 'props'], ), ), }, diff --git a/packages/genui/lib/src/model/catalog.dart b/packages/genui/lib/src/model/catalog.dart index 8838d75b0..04b8c44a5 100644 --- a/packages/genui/lib/src/model/catalog.dart +++ b/packages/genui/lib/src/model/catalog.dart @@ -56,7 +56,7 @@ class Catalog { /// Builds a Flutter widget from a JSON-like data structure. Widget buildWidget(CatalogItemContext itemContext) { final widgetData = itemContext.data as JsonMap; - final String? widgetType = widgetData.keys.firstOrNull; + final String? widgetType = widgetData['component'] as String?; final CatalogItem? item = items.firstWhereOrNull( (item) => item.name == widgetType, ); @@ -68,7 +68,7 @@ class Catalog { genUiLogger.info('Building widget ${item.name} with id ${itemContext.id}'); return item.widgetBuilder( CatalogItemContext( - data: JsonMap.from(widgetData[widgetType]! as Map), + data: widgetData, id: itemContext.id, buildChild: (String childId, [DataContext? childDataContext]) => itemContext.buildChild( diff --git a/packages/genui/lib/src/model/ui_models.dart b/packages/genui/lib/src/model/ui_models.dart index 16c2cca25..5706c90ce 100644 --- a/packages/genui/lib/src/model/ui_models.dart +++ b/packages/genui/lib/src/model/ui_models.dart @@ -88,28 +88,28 @@ class UiDefinition { Map get components => UnmodifiableMapView(_components); final Map _components; - /// (Future) The styles for this surface. - final JsonMap? styles; + /// (Future) The theme for this surface. + final JsonMap? theme; /// Creates a [UiDefinition]. UiDefinition({ required this.surfaceId, this.rootComponentId, Map components = const {}, - this.styles, + this.theme, }) : _components = components; /// Creates a copy of this [UiDefinition] with the given fields replaced. UiDefinition copyWith({ String? rootComponentId, Map? components, - JsonMap? styles, + JsonMap? theme, }) { return UiDefinition( surfaceId: surfaceId, rootComponentId: rootComponentId ?? this.rootComponentId, components: components ?? _components, - styles: styles ?? this.styles, + theme: theme ?? this.theme, ); } @@ -136,18 +136,18 @@ final class Component { /// Creates a [Component]. const Component({ required this.id, - required this.componentProperties, + required this.props, this.weight, }); /// Creates a [Component] from a JSON map. factory Component.fromJson(JsonMap json) { - if (json['component'] == null) { - throw ArgumentError('Component.fromJson: component property is null'); + if (json['props'] == null) { + throw ArgumentError('Component.fromJson: props property is null'); } return Component( id: json['id'] as String, - componentProperties: json['component'] as JsonMap, + props: json['props'] as JsonMap, weight: json['weight'] as int?, ); } @@ -156,7 +156,7 @@ final class Component { final String id; /// The properties of the component. - final JsonMap componentProperties; + final JsonMap props; /// The weight of the component, used for layout in Row/Column. final int? weight; @@ -165,13 +165,13 @@ final class Component { JsonMap toJson() { return { 'id': id, - 'component': componentProperties, + 'props': props, if (weight != null) 'weight': weight, }; } /// The type of the component. - String get type => componentProperties.keys.first; + String get type => props['component'] as String; @override bool operator ==(Object other) => @@ -179,14 +179,13 @@ final class Component { id == other.id && weight == other.weight && const DeepCollectionEquality().equals( - componentProperties, - other.componentProperties, + props, other.props, ); @override int get hashCode => Object.hash( id, weight, - const DeepCollectionEquality().hash(componentProperties), + const DeepCollectionEquality().hash(props), ); } diff --git a/packages/genui/test/catalog/core_widgets/button_test.dart b/packages/genui/test/catalog/core_widgets/button_test.dart index 26cff1ba0..25b25ecb7 100644 --- a/packages/genui/test/catalog/core_widgets/button_test.dart +++ b/packages/genui/test/catalog/core_widgets/button_test.dart @@ -20,19 +20,17 @@ void main() { final components = [ const Component( id: 'button', - componentProperties: { - 'Button': { - 'child': 'button_text', - 'action': {'name': 'testAction'}, - }, + props: { + 'component': 'Button', + 'child': 'button_text', + 'action': {'name': 'testAction'}, }, ), const Component( id: 'button_text', - componentProperties: { - 'Text': { - 'text': {'literalString': 'Click Me'}, - }, + props: { + 'component': 'Text', + 'text': {'literalString': 'Click Me'}, }, ), ]; @@ -40,7 +38,7 @@ void main() { SurfaceUpdate(surfaceId: surfaceId, components: components), ); manager.handleMessage( - const BeginRendering(surfaceId: surfaceId, root: 'button'), + const CreateSurface(surfaceId: surfaceId), ); await tester.pumpWidget( diff --git a/packages/genui/test/catalog/core_widgets/card_test.dart b/packages/genui/test/catalog/core_widgets/card_test.dart index 2c3eca5bc..5377ca0ad 100644 --- a/packages/genui/test/catalog/core_widgets/card_test.dart +++ b/packages/genui/test/catalog/core_widgets/card_test.dart @@ -16,16 +16,14 @@ void main() { final components = [ const Component( id: 'card', - componentProperties: { - 'Card': {'child': 'text'}, + props: {'component': 'Card', 'child': 'text', }, ), const Component( id: 'text', - componentProperties: { - 'Text': { - 'text': {'literalString': 'This is a card.'}, - }, + props: { + 'component': 'Text', + 'text': {'literalString': 'This is a card.'}, }, ), ]; @@ -33,7 +31,7 @@ void main() { SurfaceUpdate(surfaceId: surfaceId, components: components), ); manager.handleMessage( - const BeginRendering(surfaceId: surfaceId, root: 'card'), + const CreateSurface(surfaceId: surfaceId), ); await tester.pumpWidget( 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..03e075b3a 100644 --- a/packages/genui/test/catalog/core_widgets/check_box_test.dart +++ b/packages/genui/test/catalog/core_widgets/check_box_test.dart @@ -18,11 +18,10 @@ void main() { final components = [ const Component( id: 'checkbox', - componentProperties: { - 'CheckBox': { - 'label': {'literalString': 'Check me'}, - 'value': {'path': '/myValue'}, - }, + props: { + 'component': 'CheckBox', + 'label': {'literalString': 'Check me'}, + 'value': {'path': '/myValue'}, }, ), ]; @@ -30,7 +29,7 @@ void main() { SurfaceUpdate(surfaceId: surfaceId, components: components), ); manager.handleMessage( - const BeginRendering(surfaceId: surfaceId, root: 'checkbox'), + const CreateSurface(surfaceId: surfaceId), ); manager.dataModelForSurface(surfaceId).update(DataPath('/myValue'), true); diff --git a/packages/genui/test/catalog/core_widgets/column_test.dart b/packages/genui/test/catalog/core_widgets/column_test.dart index a23e54cd8..ef19848d1 100644 --- a/packages/genui/test/catalog/core_widgets/column_test.dart +++ b/packages/genui/test/catalog/core_widgets/column_test.dart @@ -16,28 +16,25 @@ void main() { final components = [ const Component( id: 'column', - componentProperties: { - 'Column': { - 'children': { - 'explicitList': ['text1', 'text2'], - }, + props: { + 'component': 'Column', + 'children': { + 'explicitList': ['text1', 'text2'], }, }, ), const Component( id: 'text1', - componentProperties: { - 'Text': { - 'text': {'literalString': 'First'}, - }, + props: { + 'component': 'Text', + 'text': {'literalString': 'First'}, }, ), const Component( id: 'text2', - componentProperties: { - 'Text': { - 'text': {'literalString': 'Second'}, - }, + props: { + 'component': 'Text', + 'text': {'literalString': 'Second'}, }, ), ]; @@ -45,7 +42,7 @@ void main() { SurfaceUpdate(surfaceId: surfaceId, components: components), ); manager.handleMessage( - const BeginRendering(surfaceId: surfaceId, root: 'column'), + const CreateSurface(surfaceId: surfaceId), ); await tester.pumpWidget( @@ -71,38 +68,35 @@ void main() { final components = [ const Component( id: 'column', - componentProperties: { - 'Column': { - 'children': { - 'explicitList': ['text1', 'text2', 'text3'], - }, + props: { + 'component': 'Column', + 'children': { + 'explicitList': ['text1', 'text2', 'text3'], }, }, ), const Component( id: 'text1', - componentProperties: { - 'Text': { - 'text': {'literalString': 'First'}, - }, + props: { + 'component': 'Text', + 'text': {'literalString': 'First'}, }, weight: 1, ), const Component( id: 'text2', - componentProperties: { - 'Text': { - 'text': {'literalString': 'Second'}, - }, + props: { + 'component': 'Text', + 'text': {'literalString': 'Second'}, }, weight: 2, ), const Component( id: 'text3', - componentProperties: { - 'Text': { - 'text': {'literalString': 'Third'}, - }, + props: { + 'component': 'Text', + 'text': {'literalString': 'Third'}, + 'weight': 0, }, ), ]; @@ -110,7 +104,7 @@ void main() { SurfaceUpdate(surfaceId: surfaceId, components: components), ); manager.handleMessage( - const BeginRendering(surfaceId: surfaceId, root: 'column'), + const CreateSurface(surfaceId: surfaceId), ); await tester.pumpWidget( 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..7997734c7 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 @@ -18,10 +18,9 @@ void main() { final components = [ const Component( id: 'datetime', - componentProperties: { - 'DateTimeInput': { - 'value': {'path': '/myDateTime'}, - }, + props: { + 'component': 'DateTimeInput', + 'value': {'path': '/myDateTime'}, }, ), ]; @@ -29,7 +28,7 @@ void main() { SurfaceUpdate(surfaceId: surfaceId, components: components), ); manager.handleMessage( - const BeginRendering(surfaceId: surfaceId, root: 'datetime'), + const CreateSurface(surfaceId: surfaceId), ); manager .dataModelForSurface(surfaceId) diff --git a/packages/genui/test/catalog/core_widgets/divider_test.dart b/packages/genui/test/catalog/core_widgets/divider_test.dart index 78ede0e94..0359193f2 100644 --- a/packages/genui/test/catalog/core_widgets/divider_test.dart +++ b/packages/genui/test/catalog/core_widgets/divider_test.dart @@ -16,14 +16,14 @@ void main() { final components = [ const Component( id: 'divider', - componentProperties: {'Divider': {}}, + props: {'component': 'Divider'}, ), ]; manager.handleMessage( SurfaceUpdate(surfaceId: surfaceId, components: components), ); manager.handleMessage( - const BeginRendering(surfaceId: surfaceId, root: 'divider'), + const CreateSurface(surfaceId: surfaceId), ); await tester.pumpWidget( diff --git a/packages/genui/test/catalog/core_widgets/icon_test.dart b/packages/genui/test/catalog/core_widgets/icon_test.dart index 8e6673619..1f3cda5a7 100644 --- a/packages/genui/test/catalog/core_widgets/icon_test.dart +++ b/packages/genui/test/catalog/core_widgets/icon_test.dart @@ -18,10 +18,9 @@ void main() { final components = [ const Component( id: 'icon', - componentProperties: { - 'Icon': { - 'name': {'literalString': 'add'}, - }, + props: { + 'component': 'Icon', + 'name': {'literalString': 'add'}, }, ), ]; @@ -29,7 +28,7 @@ void main() { SurfaceUpdate(surfaceId: surfaceId, components: components), ); manager.handleMessage( - const BeginRendering(surfaceId: surfaceId, root: 'icon'), + const CreateSurface(surfaceId: surfaceId), ); await tester.pumpWidget( @@ -54,10 +53,9 @@ void main() { final components = [ const Component( id: 'icon', - componentProperties: { - 'Icon': { - 'name': {'path': '/iconName'}, - }, + props: { + 'component': 'Icon', + 'name': {'path': '/iconName'}, }, ), ]; @@ -72,7 +70,7 @@ void main() { ), ); manager.handleMessage( - const BeginRendering(surfaceId: surfaceId, root: 'icon'), + const CreateSurface(surfaceId: surfaceId), ); await tester.pumpWidget( diff --git a/packages/genui/test/catalog/core_widgets/list_test.dart b/packages/genui/test/catalog/core_widgets/list_test.dart index 5b7fa650c..2416d5e88 100644 --- a/packages/genui/test/catalog/core_widgets/list_test.dart +++ b/packages/genui/test/catalog/core_widgets/list_test.dart @@ -16,28 +16,25 @@ void main() { final components = [ const Component( id: 'list', - componentProperties: { - 'List': { - 'children': { - 'explicitList': ['text1', 'text2'], - }, + props: { + 'component': 'List', + 'children': { + 'explicitList': ['text1', 'text2'], }, }, ), const Component( id: 'text1', - componentProperties: { - 'Text': { - 'text': {'literalString': 'First'}, - }, + props: { + 'component': 'Text', + 'text': {'literalString': 'First'}, }, ), const Component( id: 'text2', - componentProperties: { - 'Text': { - 'text': {'literalString': 'Second'}, - }, + props: { + 'component': 'Text', + 'text': {'literalString': 'Second'}, }, ), ]; @@ -45,7 +42,7 @@ void main() { SurfaceUpdate(surfaceId: surfaceId, components: components), ); manager.handleMessage( - const BeginRendering(surfaceId: surfaceId, root: 'list'), + const CreateSurface(surfaceId: surfaceId), ); await tester.pumpWidget( diff --git a/packages/genui/test/catalog/core_widgets/modal_test.dart b/packages/genui/test/catalog/core_widgets/modal_test.dart index dfe1b4254..fbebfd2dc 100644 --- a/packages/genui/test/catalog/core_widgets/modal_test.dart +++ b/packages/genui/test/catalog/core_widgets/modal_test.dart @@ -22,41 +22,40 @@ void main() { final components = [ const Component( id: 'modal', - componentProperties: { - 'Modal': {'entryPointChild': 'button', 'contentChild': 'text'}, + props: { + 'component': 'Modal', + 'entryPointChild': 'button', + 'contentChild': 'text', }, ), const Component( id: 'button', - componentProperties: { - 'Button': { - 'child': 'button_text', - 'action': { - 'name': 'showModal', - 'context': [ - { - 'key': 'modalId', - 'value': {'literalString': 'modal'}, - }, - ], - }, + props: { + 'component': 'Button', + 'child': 'button_text', + 'action': { + 'name': 'showModal', + 'context': [ + { + 'key': 'modalId', + 'value': {'literalString': 'modal'}, + }, + ], }, }, ), const Component( id: 'button_text', - componentProperties: { - 'Text': { - 'text': {'literalString': 'Open Modal'}, - }, + props: { + 'component': 'Text', + 'text': {'literalString': 'Open Modal'}, }, ), const Component( id: 'text', - componentProperties: { - 'Text': { - 'text': {'literalString': 'This is a modal.'}, - }, + props: { + 'component': 'Text', + 'text': {'literalString': 'This is a modal.'}, }, ), ]; @@ -64,7 +63,7 @@ void main() { SurfaceUpdate(surfaceId: surfaceId, components: components), ); manager.handleMessage( - const BeginRendering(surfaceId: surfaceId, root: 'modal'), + const CreateSurface(surfaceId: surfaceId), ); await tester.pumpWidget( 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..79b3e9199 100644 --- a/packages/genui/test/catalog/core_widgets/multiple_choice_test.dart +++ b/packages/genui/test/catalog/core_widgets/multiple_choice_test.dart @@ -18,20 +18,19 @@ void main() { final components = [ const Component( id: 'multiple_choice', - componentProperties: { - 'MultipleChoice': { - 'selections': {'path': '/mySelections'}, - 'options': [ - { - 'label': {'literalString': 'Option 1'}, - 'value': '1', - }, - { - 'label': {'literalString': 'Option 2'}, - 'value': '2', - }, - ], - }, + props: { + 'component': 'MultipleChoice', + 'selections': {'path': '/mySelections'}, + 'options': [ + { + 'label': {'literalString': 'Option 1'}, + 'value': '1', + }, + { + 'label': {'literalString': 'Option 2'}, + 'value': '2', + }, + ], }, ), ]; @@ -39,7 +38,7 @@ void main() { SurfaceUpdate(surfaceId: surfaceId, components: components), ); manager.handleMessage( - const BeginRendering(surfaceId: surfaceId, root: 'multiple_choice'), + const CreateSurface(surfaceId: surfaceId), ); manager.dataModelForSurface(surfaceId).update(DataPath('/mySelections'), [ '1', diff --git a/packages/genui/test/catalog/core_widgets/row_test.dart b/packages/genui/test/catalog/core_widgets/row_test.dart index a5543506e..5baf986ef 100644 --- a/packages/genui/test/catalog/core_widgets/row_test.dart +++ b/packages/genui/test/catalog/core_widgets/row_test.dart @@ -16,28 +16,25 @@ void main() { final components = [ const Component( id: 'row', - componentProperties: { - 'Row': { - 'children': { - 'explicitList': ['text1', 'text2'], - }, + props: { + 'component': 'Row', + 'children': { + 'explicitList': ['text1', 'text2'], }, }, ), const Component( id: 'text1', - componentProperties: { - 'Text': { - 'text': {'literalString': 'First'}, - }, + props: { + 'component': 'Text', + 'text': {'literalString': 'First'}, }, ), const Component( id: 'text2', - componentProperties: { - 'Text': { - 'text': {'literalString': 'Second'}, - }, + props: { + 'component': 'Text', + 'text': {'literalString': 'Second'}, }, ), ]; @@ -45,7 +42,7 @@ void main() { SurfaceUpdate(surfaceId: surfaceId, components: components), ); manager.handleMessage( - const BeginRendering(surfaceId: surfaceId, root: 'row'), + const CreateSurface(surfaceId: surfaceId), ); await tester.pumpWidget( @@ -71,38 +68,34 @@ void main() { final components = [ const Component( id: 'row', - componentProperties: { - 'Row': { - 'children': { - 'explicitList': ['text1', 'text2', 'text3'], - }, + props: { + 'component': 'Row', + 'children': { + 'explicitList': ['text1', 'text2', 'text3'], }, }, ), const Component( id: 'text1', - componentProperties: { - 'Text': { - 'text': {'literalString': 'First'}, - }, + props: { + 'component': 'Text', + 'text': {'literalString': 'First'}, }, weight: 1, ), const Component( id: 'text2', - componentProperties: { - 'Text': { - 'text': {'literalString': 'Second'}, - }, + props: { + 'component': 'Text', + 'text': {'literalString': 'Second'}, }, weight: 2, ), const Component( id: 'text3', - componentProperties: { - 'Text': { - 'text': {'literalString': 'Third'}, - }, + props: { + 'component': 'Text', + 'text': {'literalString': 'Third'}, }, ), ]; @@ -110,7 +103,7 @@ void main() { SurfaceUpdate(surfaceId: surfaceId, components: components), ); manager.handleMessage( - const BeginRendering(surfaceId: surfaceId, root: 'row'), + const CreateSurface(surfaceId: surfaceId), ); await tester.pumpWidget( diff --git a/packages/genui/test/catalog/core_widgets/slider_test.dart b/packages/genui/test/catalog/core_widgets/slider_test.dart index 5a28fc0ab..1d8714449 100644 --- a/packages/genui/test/catalog/core_widgets/slider_test.dart +++ b/packages/genui/test/catalog/core_widgets/slider_test.dart @@ -18,10 +18,9 @@ void main() { final components = [ const Component( id: 'slider', - componentProperties: { - 'Slider': { - 'value': {'path': '/myValue'}, - }, + props: { + 'component': 'Slider', + 'value': {'path': '/myValue'}, }, ), ]; @@ -29,7 +28,7 @@ void main() { SurfaceUpdate(surfaceId: surfaceId, components: components), ); manager.handleMessage( - const BeginRendering(surfaceId: surfaceId, root: 'slider'), + const CreateSurface(surfaceId: surfaceId), ); manager.dataModelForSurface(surfaceId).update(DataPath('/myValue'), 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..7d106c33f 100644 --- a/packages/genui/test/catalog/core_widgets/tabs_test.dart +++ b/packages/genui/test/catalog/core_widgets/tabs_test.dart @@ -18,24 +18,23 @@ void main() { final components = [ const Component( id: 'tabs', - componentProperties: { - 'Tabs': { - 'tabItems': [ - { - 'title': {'literalString': 'Tab 1'}, - 'child': 'text1', - }, - { - 'title': {'literalString': 'Tab 2'}, - 'child': 'text2', - }, - ], - }, + props: { + 'component': 'Tabs', + 'tabItems': [ + { + 'title': {'literalString': 'Tab 1'}, + 'child': 'text1', + }, + { + 'title': {'literalString': 'Tab 2'}, + 'child': 'text2', + }, + ], }, ), const Component( id: 'text1', - componentProperties: { + props: { 'Text': { 'text': {'literalString': 'This is the first tab.'}, }, @@ -43,7 +42,7 @@ void main() { ), const Component( id: 'text2', - componentProperties: { + props: { 'Text': { 'text': {'literalString': 'This is the second tab.'}, }, @@ -54,7 +53,7 @@ void main() { SurfaceUpdate(surfaceId: surfaceId, components: components), ); manager.handleMessage( - const BeginRendering(surfaceId: surfaceId, root: 'tabs'), + const CreateSurface(surfaceId: surfaceId), ); await tester.pumpWidget( diff --git a/packages/genui/test/catalog/core_widgets/text_test.dart b/packages/genui/test/catalog/core_widgets/text_test.dart index c70416e2c..bb782e6f5 100644 --- a/packages/genui/test/catalog/core_widgets/text_test.dart +++ b/packages/genui/test/catalog/core_widgets/text_test.dart @@ -21,6 +21,7 @@ void main() { body: text.widgetBuilder( CatalogItemContext( data: { + 'component': 'Text', 'text': {'literalString': 'Hello World'}, }, id: 'test_text', @@ -50,6 +51,7 @@ void main() { body: text.widgetBuilder( CatalogItemContext( data: { + 'component': 'Text', 'text': {'literalString': 'Heading 1'}, 'usageHint': 'h1', }, diff --git a/packages/genui/test/catalog/core_widgets_test.dart b/packages/genui/test/catalog/core_widgets_test.dart index 7b4041977..00e0c8737 100644 --- a/packages/genui/test/catalog/core_widgets_test.dart +++ b/packages/genui/test/catalog/core_widgets_test.dart @@ -30,7 +30,7 @@ void main() { SurfaceUpdate(surfaceId: surfaceId, components: components), ); manager!.handleMessage( - BeginRendering(surfaceId: surfaceId, root: rootId), + const CreateSurface(surfaceId: surfaceId), ); await tester.pumpWidget( MaterialApp( @@ -45,7 +45,7 @@ void main() { final components = [ const Component( id: 'button', - componentProperties: { + props: { 'Button': { 'child': 'text', 'action': {'name': 'testAction'}, @@ -54,7 +54,7 @@ void main() { ), const Component( id: 'text', - componentProperties: { + props: { 'Text': { 'text': {'literalString': 'Click Me'}, }, @@ -75,7 +75,7 @@ void main() { final components = [ const Component( id: 'text', - componentProperties: { + props: { 'Text': { 'text': {'path': '/myText'}, }, @@ -96,7 +96,7 @@ void main() { final components = [ const Component( id: 'col', - componentProperties: { + props: { 'Column': { 'children': { 'explicitList': ['text1', 'text2'], @@ -106,7 +106,7 @@ void main() { ), const Component( id: 'text1', - componentProperties: { + props: { 'Text': { 'text': {'literalString': 'First'}, }, @@ -114,7 +114,7 @@ void main() { ), const Component( id: 'text2', - componentProperties: { + props: { 'Text': { 'text': {'literalString': 'Second'}, }, @@ -134,7 +134,7 @@ void main() { final components = [ const Component( id: 'field', - componentProperties: { + props: { 'TextField': { 'text': {'path': '/myValue'}, 'label': {'literalString': 'My Label'}, diff --git a/packages/genui/test/catalog_test.dart b/packages/genui/test/catalog_test.dart index be04c9c29..7dbc85836 100644 --- a/packages/genui/test/catalog_test.dart +++ b/packages/genui/test/catalog_test.dart @@ -15,10 +15,9 @@ void main() { ) async { final catalog = Catalog([CoreCatalogItems.column, CoreCatalogItems.text]); final widgetData = { - 'Column': { - 'children': { - 'explicitList': ['child1'], - }, + 'component': 'Column', + 'children': { + 'explicitList': ['child1'], }, }; @@ -57,7 +56,7 @@ void main() { final Map data = { 'id': 'text1', 'widget': { - 'unknown_widget': {'text': 'hello'}, + 'component': 'unknown_widget', 'text': 'hello', }, }; diff --git a/packages/genui/test/core/genui_manager_test.dart b/packages/genui/test/core/genui_manager_test.dart index 2e8c078c3..2f27502bc 100644 --- a/packages/genui/test/core/genui_manager_test.dart +++ b/packages/genui/test/core/genui_manager_test.dart @@ -35,7 +35,7 @@ void main() { final components = [ const Component( id: 'root', - componentProperties: { + props: { 'Text': {'text': 'Hello'}, }, ), @@ -51,7 +51,7 @@ void main() { final Future futureUpdated = manager.surfaceUpdates.first; manager.handleMessage( - const BeginRendering(surfaceId: surfaceId, root: 'root'), + const CreateSurface(surfaceId: surfaceId), ); final GenUiUpdate updatedUpdate = await futureUpdated; @@ -60,9 +60,9 @@ void main() { final UiDefinition definition = (updatedUpdate as SurfaceUpdated).definition; expect(definition, isNotNull); - expect(definition.rootComponentId, 'root'); + // expect(definition.rootComponentId, 'root'); // CreateSurface no longer sets root expect(manager.surfaces[surfaceId]!.value, isNotNull); - expect(manager.surfaces[surfaceId]!.value!.rootComponentId, 'root'); + // expect(manager.surfaces[surfaceId]!.value!.rootComponentId, 'root'); }); test( @@ -72,7 +72,7 @@ void main() { final oldComponents = [ const Component( id: 'root', - componentProperties: { + props: { 'Text': {'text': 'Old'}, }, ), @@ -84,7 +84,7 @@ void main() { final newComponents = [ const Component( id: 'root', - componentProperties: { + props: { 'Text': {'text': 'New'}, }, ), @@ -110,7 +110,7 @@ void main() { final components = [ const Component( id: 'root', - componentProperties: { + props: { 'Text': {'text': 'Hello'}, }, ), diff --git a/packages/genui/test/core/ui_tools_test.dart b/packages/genui/test/core/ui_tools_test.dart index 9a9ca8d9a..f28d42563 100644 --- a/packages/genui/test/core/ui_tools_test.dart +++ b/packages/genui/test/core/ui_tools_test.dart @@ -40,7 +40,7 @@ void main() { 'components': [ { 'id': 'rootWidget', - 'component': { + 'props': { 'Text': {'text': 'Hello'}, }, }, @@ -55,7 +55,7 @@ void main() { expect(surfaceUpdate.surfaceId, 'testSurface'); expect(surfaceUpdate.components.length, 1); expect(surfaceUpdate.components[0].id, 'rootWidget'); - expect(surfaceUpdate.components[0].componentProperties, { + expect(surfaceUpdate.components[0].props, { 'Text': {'text': 'Hello'}, }); }); @@ -82,7 +82,7 @@ void main() { }); }); - group('BeginRenderingTool', () { + group('CreateSurfaceTool', () { test('invoke calls handleMessage with correct arguments', () async { final messages = []; @@ -90,20 +90,18 @@ void main() { messages.add(message); } - final tool = BeginRenderingTool(handleMessage: fakeHandleMessage); + final tool = CreateSurfaceTool(handleMessage: fakeHandleMessage); - final Map args = { + final Map args = { surfaceIdKey: 'testSurface', - 'root': 'rootWidget', }; await tool.invoke(args); expect(messages.length, 1); - expect(messages[0], isA()); - final beginRendering = messages[0] as BeginRendering; - expect(beginRendering.surfaceId, 'testSurface'); - expect(beginRendering.root, 'rootWidget'); + expect(messages[0], isA()); + final createSurface = messages[0] as CreateSurface; + expect(createSurface.surfaceId, 'testSurface'); }); }); } diff --git a/packages/genui/test/genui_surface_test.dart b/packages/genui/test/genui_surface_test.dart index 6efad683f..a9180bcad 100644 --- a/packages/genui/test/genui_surface_test.dart +++ b/packages/genui/test/genui_surface_test.dart @@ -20,19 +20,17 @@ void main() { final components = [ const Component( id: 'root', - componentProperties: { - 'Button': { - 'child': 'text', - 'action': {'name': 'testAction'}, - }, + props: { + 'component': 'Button', + 'child': 'text', + 'action': {'name': 'testAction'}, }, ), const Component( id: 'text', - componentProperties: { - 'Text': { - 'text': {'literalString': 'Hello'}, - }, + props: { + 'component': 'Text', + 'text': {'literalString': 'Hello'}, }, ), ]; @@ -40,7 +38,7 @@ void main() { SurfaceUpdate(surfaceId: surfaceId, components: components), ); manager.handleMessage( - const BeginRendering(surfaceId: surfaceId, root: 'root'), + const CreateSurface(surfaceId: surfaceId), ); await tester.pumpWidget( @@ -62,19 +60,17 @@ void main() { final components = [ const Component( id: 'root', - componentProperties: { - 'Button': { - 'child': 'text', - 'action': {'name': 'testAction'}, - }, + props: { + 'component': 'Button', + 'child': 'text', + 'action': {'name': 'testAction'}, }, ), const Component( id: 'text', - componentProperties: { - 'Text': { - 'text': {'literalString': 'Hello'}, - }, + props: { + 'component': 'Text', + 'text': {'literalString': 'Hello'}, }, ), ]; @@ -82,7 +78,7 @@ void main() { SurfaceUpdate(surfaceId: surfaceId, components: components), ); manager.handleMessage( - const BeginRendering(surfaceId: surfaceId, root: 'root'), + const CreateSurface(surfaceId: surfaceId), ); await tester.pumpWidget( diff --git a/packages/genui/test/model/ui_definition_test.dart b/packages/genui/test/model/ui_definition_test.dart index 11d119ba2..3c88146ca 100644 --- a/packages/genui/test/model/ui_definition_test.dart +++ b/packages/genui/test/model/ui_definition_test.dart @@ -14,7 +14,7 @@ void main() { components: { 'root': const Component( id: 'root', - componentProperties: { + props: { 'Text': {'text': 'Hello'}, }, ), @@ -28,7 +28,7 @@ void main() { expect(json['components'], { 'root': { 'id': 'root', - 'component': { + 'props': { 'Text': {'text': 'Hello'}, }, }, diff --git a/packages/genui/test/ui_tools_test.dart b/packages/genui/test/ui_tools_test.dart index 754acd90e..03f4b975d 100644 --- a/packages/genui/test/ui_tools_test.dart +++ b/packages/genui/test/ui_tools_test.dart @@ -36,7 +36,7 @@ void main() { 'components': [ { 'id': 'root', - 'component': { + 'props': { 'Text': { 'text': {'literalString': 'Hello'}, }, @@ -68,14 +68,13 @@ void main() { await future; }); - test('BeginRenderingTool sends BeginRendering message', () async { - final tool = BeginRenderingTool( + test('CreateSurfaceTool sends CreateSurface message', () async { + final tool = CreateSurfaceTool( handleMessage: genUiManager.handleMessage, ); final Map args = { surfaceIdKey: 'testSurface', - 'root': 'root', }; // First, add a component to the surface so that the root can be set. @@ -85,7 +84,7 @@ void main() { components: [ Component( id: 'root', - componentProperties: { + props: { 'Text': { 'text': {'literalString': 'Hello'}, }, @@ -100,12 +99,11 @@ void main() { genUiManager.surfaceUpdates, emits( isA() - .having((e) => e.surfaceId, surfaceIdKey, 'testSurface') .having( - (e) => e.definition.rootComponentId, - 'rootComponentId', - 'root', - ), + (e) => e.surfaceId, + surfaceIdKey, + 'testSurface', + ), ), ); diff --git a/packages/genui_a2ui/lib/src/a2ui_agent_connector.dart b/packages/genui_a2ui/lib/src/a2ui_agent_connector.dart index 48754846a..8cd43511c 100644 --- a/packages/genui_a2ui/lib/src/a2ui_agent_connector.dart +++ b/packages/genui_a2ui/lib/src/a2ui_agent_connector.dart @@ -122,6 +122,17 @@ class A2uiAgentConnector { } }).toList(); + // Add client capabilities + message.parts!.add( + A2ADataPart() + ..data = { + 'clientUiCapabilities': { + 'inlineCatalog': true, + 'supportedComponents': ['*'], // Or list specific components + }, + }, + ); + if (taskId != null) { message.referenceTaskIds = [taskId!]; } @@ -130,7 +141,7 @@ class A2uiAgentConnector { } final payload = A2AMessageSendParams()..message = message; - payload.extensions = ['https://a2ui.org/ext/a2a-ui/v0.1']; + payload.extensions = ['https://a2ui.org/ext/a2a-ui/v0.9']; _log.info('--- OUTGOING REQUEST ---'); _log.info('URL: ${url.toString()}'); @@ -216,10 +227,10 @@ class A2uiAgentConnector { } final Map clientEvent = { - 'actionName': event['action'], + 'action': event['action'], 'sourceComponentId': event['sourceComponentId'], 'timestamp': DateTime.now().toIso8601String(), - 'resolvedContext': event['context'], + 'context': event['context'], }; _log.finest('Sending client event: $clientEvent'); @@ -251,7 +262,7 @@ class A2uiAgentConnector { ); if (data.containsKey('surfaceUpdate') || data.containsKey('dataModelUpdate') || - data.containsKey('beginRendering') || + data.containsKey('createSurface') || data.containsKey('deleteSurface')) { if (!_controller.isClosed) { _log.finest( diff --git a/packages/genui_a2ui/test/a2ui_agent_connector_test.dart b/packages/genui_a2ui/test/a2ui_agent_connector_test.dart index a1380bc92..4f1178435 100644 --- a/packages/genui_a2ui/test/a2ui_agent_connector_test.dart +++ b/packages/genui_a2ui/test/a2ui_agent_connector_test.dart @@ -58,8 +58,9 @@ void main() { 'components': [ { 'id': 'c1', - 'component': { - 'Column': {'children': []}, + 'props': { + 'component': 'Column', + 'children': [], }, }, ], @@ -84,9 +85,13 @@ void main() { expect(fakeClient.lastSendMessageParams, isNotNull); final a2a.A2AMessage sentMessage = fakeClient.lastSendMessageParams!.message; - expect(sentMessage.parts!.length, 2); + expect(sentMessage.parts!.length, 3); // +1 for clientUiCapabilities expect((sentMessage.parts![0] as a2a.A2ATextPart).text, 'Hi'); expect((sentMessage.parts![1] as a2a.A2ATextPart).text, 'There'); + expect( + sentMessage.parts![2], + isA(), + ); // clientUiCapabilities expect(connector.taskId, 'task1'); expect(connector.contextId, 'context1'); expect(fakeClient.sendMessageStreamCalled, 1); @@ -115,7 +120,7 @@ void main() { expect(fakeClient.lastSendMessageParams, isNotNull); final a2a.A2AMessage sentMessage = fakeClient.lastSendMessageParams!.message; - expect(sentMessage.parts!.length, 2); + expect(sentMessage.parts!.length, 3); // +1 for clientUiCapabilities expect((sentMessage.parts![0] as a2a.A2ATextPart).text, 'Hello'); expect((sentMessage.parts![1] as a2a.A2ATextPart).text, 'World'); }); @@ -157,9 +162,9 @@ void main() { expect(sentMessage.contextId, 'context1'); final dataPart = sentMessage.parts!.first as a2a.A2ADataPart; final a2uiEvent = dataPart.data['a2uiEvent'] as Map; - expect(a2uiEvent['actionName'], 'testAction'); + expect(a2uiEvent['action'], 'testAction'); expect(a2uiEvent['sourceComponentId'], 'c1'); - expect(a2uiEvent['resolvedContext'], {'key': 'value'}); + expect(a2uiEvent['context'], {'key': 'value'}); }); test('sendEvent does nothing if taskId is null', () async { diff --git a/packages/genui_a2ui/test/a2ui_content_generator_test.dart b/packages/genui_a2ui/test/a2ui_content_generator_test.dart index 90966355e..dbf7103a5 100644 --- a/packages/genui_a2ui/test/a2ui_content_generator_test.dart +++ b/packages/genui_a2ui/test/a2ui_content_generator_test.dart @@ -82,8 +82,7 @@ void main() { 'components': [ { 'id': 'c1', - 'component': { - 'Column': {'children': []}, + 'props': {'component': 'Column', 'children': [], }, }, ], diff --git a/packages/genui_firebase_ai/lib/src/firebase_ai_content_generator.dart b/packages/genui_firebase_ai/lib/src/firebase_ai_content_generator.dart index 09463db31..50058fb09 100644 --- a/packages/genui_firebase_ai/lib/src/firebase_ai_content_generator.dart +++ b/packages/genui_firebase_ai/lib/src/firebase_ai_content_generator.dart @@ -350,7 +350,7 @@ class FirebaseAiContentGenerator implements ContentGenerator { catalog: catalog, configuration: configuration, ), - BeginRenderingTool(handleMessage: _a2uiMessageController.add), + CreateSurfaceTool(handleMessage: _a2uiMessageController.add), ], if (configuration.actions.allowDelete) DeleteSurfaceTool(handleMessage: _a2uiMessageController.add), @@ -377,11 +377,16 @@ class FirebaseAiContentGenerator implements ContentGenerator { const maxToolUsageCycles = 40; // Safety break for tool loops Object? capturedResult; + final String definition = const JsonEncoder.withIndent( + ' ', + ).convert(catalog.definition.toJson()); final GeminiGenerativeModelInterface model = modelCreator( configuration: this, - systemInstruction: systemInstruction == null - ? null - : Content.system(systemInstruction!), + systemInstruction: Content.system( + '${systemInstruction ?? ''}\n\n' + 'You have access to the following UI components:\n' + '$definition', + ), tools: generativeAiTools, toolConfig: isForcedToolCalling ? ToolConfig( diff --git a/packages/genui_google_generative_ai/lib/src/google_generative_ai_content_generator.dart b/packages/genui_google_generative_ai/lib/src/google_generative_ai_content_generator.dart index 5a5683746..17560caf3 100644 --- a/packages/genui_google_generative_ai/lib/src/google_generative_ai_content_generator.dart +++ b/packages/genui_google_generative_ai/lib/src/google_generative_ai_content_generator.dart @@ -351,7 +351,7 @@ class GoogleGenerativeAiContentGenerator implements ContentGenerator { catalog: catalog, configuration: configuration, ), - BeginRenderingTool(handleMessage: _a2uiMessageController.add), + CreateSurfaceTool(handleMessage: _a2uiMessageController.add), ], if (configuration.actions.allowDelete) DeleteSurfaceTool(handleMessage: _a2uiMessageController.add), @@ -375,13 +375,19 @@ class GoogleGenerativeAiContentGenerator implements ContentGenerator { Object? capturedResult; // Build system instruction if provided - final systemInstructionContent = systemInstruction != null - ? [ - google_ai.Content( - parts: [google_ai.Part(text: systemInstruction)], - ), - ] - : []; + final definition = const JsonEncoder.withIndent( + ' ', + ).convert(catalog.definition.toJson()); + final effectiveSystemInstruction = + '${systemInstruction ?? ''}\n\n' + 'You have access to the following UI components:\n' + '$definition'; + + final systemInstructionContent = [ + google_ai.Content( + parts: [google_ai.Part(text: effectiveSystemInstruction)], + ), + ]; while (toolUsageCycle < maxToolUsageCycles) { genUiLogger.fine('Starting tool usage cycle ${toolUsageCycle + 1}.'); From 3a8b359c9e77cbaa84981f353f250ef5cc20c564 Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Fri, 21 Nov 2025 14:49:22 -0800 Subject: [PATCH 05/11] works --- examples/catalog_gallery/lib/main.dart | 4 + .../catalog_gallery/lib/sample_parser.dart | 3 + .../catalog_gallery/lib/samples_view.dart | 9 +- examples/catalog_gallery/pubspec.yaml | 1 + .../samples/hello_world.sample | 1 + packages/genui/lib/src/model/catalog.dart | 2 +- packages/genui/test/catalog_test.dart | 2 +- .../src/firebase_ai_content_generator.dart | 385 +++++------------- .../lib/src/gemini_generative_model.dart | 12 + .../firebase_ai_content_generator_test.dart | 21 +- .../test/test_infra/utils.dart | 24 ++ ...oogle_generative_ai_content_generator.dart | 378 +++++------------ .../google_generative_service_interface.dart | 12 + ..._generative_ai_content_generator_test.dart | 69 +--- 14 files changed, 294 insertions(+), 629 deletions(-) diff --git a/examples/catalog_gallery/lib/main.dart b/examples/catalog_gallery/lib/main.dart index 13bc8f466..7bf9705df 100644 --- a/examples/catalog_gallery/lib/main.dart +++ b/examples/catalog_gallery/lib/main.dart @@ -3,11 +3,13 @@ // found in the LICENSE file. import 'dart:convert'; + import 'package:args/args.dart'; import 'package:file/file.dart'; import 'package:file/local.dart'; import 'package:flutter/material.dart'; import 'package:genui/genui.dart'; +import 'package:logging/logging.dart'; import 'samples_view.dart'; @@ -28,6 +30,8 @@ void main(List args) { } } + configureGenUiLogging(level: Level.ALL); + runApp(CatalogGalleryApp(samplesDir: samplesDir, fs: fs)); } diff --git a/examples/catalog_gallery/lib/sample_parser.dart b/examples/catalog_gallery/lib/sample_parser.dart index e913201c9..4417936f6 100644 --- a/examples/catalog_gallery/lib/sample_parser.dart +++ b/examples/catalog_gallery/lib/sample_parser.dart @@ -28,6 +28,9 @@ class SampleParser { static Sample parseString(String content) { final List lines = const LineSplitter().convert(content); + if (lines.firstOrNull == '---') { + lines.removeAt(0); + } final int separatorIndex = lines.indexOf('---'); if (separatorIndex == -1) { diff --git a/examples/catalog_gallery/lib/samples_view.dart b/examples/catalog_gallery/lib/samples_view.dart index dc53dcef3..5b9e57a85 100644 --- a/examples/catalog_gallery/lib/samples_view.dart +++ b/examples/catalog_gallery/lib/samples_view.dart @@ -98,6 +98,7 @@ class _SamplesViewState extends State { .toList(); setState(() { _sampleFiles = files; + _sampleFiles.sort((a, b) => a.path.compareTo(b.path)); }); } @@ -129,11 +130,13 @@ class _SamplesViewState extends State { ); }, ); - } catch (e) { - print('Error parsing sample: $e'); + } catch (exception, stackTrace) { + print('Error parsing sample: $exception\n$stackTrace'); ScaffoldMessenger.of( context, - ).showSnackBar(SnackBar(content: Text('Error parsing sample: $e'))); + ).showSnackBar( + SnackBar(content: Text('Error parsing sample: $exception')), + ); } } diff --git a/examples/catalog_gallery/pubspec.yaml b/examples/catalog_gallery/pubspec.yaml index 2b9381583..79fc70b05 100644 --- a/examples/catalog_gallery/pubspec.yaml +++ b/examples/catalog_gallery/pubspec.yaml @@ -19,6 +19,7 @@ dependencies: sdk: flutter genui: ^0.5.1 json_schema_builder: ^0.1.3 + logging: ^1.3.0 yaml: ^3.1.3 dev_dependencies: diff --git a/examples/catalog_gallery/samples/hello_world.sample b/examples/catalog_gallery/samples/hello_world.sample index 16cd345cb..e83e3ac13 100644 --- a/examples/catalog_gallery/samples/hello_world.sample +++ b/examples/catalog_gallery/samples/hello_world.sample @@ -1,3 +1,4 @@ +--- name: Test Sample description: This is a test sample to verify the parser. --- diff --git a/packages/genui/lib/src/model/catalog.dart b/packages/genui/lib/src/model/catalog.dart index 04b8c44a5..e510e9dba 100644 --- a/packages/genui/lib/src/model/catalog.dart +++ b/packages/genui/lib/src/model/catalog.dart @@ -56,7 +56,7 @@ class Catalog { /// Builds a Flutter widget from a JSON-like data structure. Widget buildWidget(CatalogItemContext itemContext) { final widgetData = itemContext.data as JsonMap; - final String? widgetType = widgetData['component'] as String?; + final widgetType = widgetData['component'] as String?; final CatalogItem? item = items.firstWhereOrNull( (item) => item.name == widgetType, ); diff --git a/packages/genui/test/catalog_test.dart b/packages/genui/test/catalog_test.dart index 7dbc85836..2d6cfe3b4 100644 --- a/packages/genui/test/catalog_test.dart +++ b/packages/genui/test/catalog_test.dart @@ -14,7 +14,7 @@ void main() { WidgetTester tester, ) async { final catalog = Catalog([CoreCatalogItems.column, CoreCatalogItems.text]); - final widgetData = { + final Map widgetData = { 'component': 'Column', 'children': { 'explicitList': ['child1'], diff --git a/packages/genui_firebase_ai/lib/src/firebase_ai_content_generator.dart b/packages/genui_firebase_ai/lib/src/firebase_ai_content_generator.dart index 50058fb09..5722fbfd0 100644 --- a/packages/genui_firebase_ai/lib/src/firebase_ai_content_generator.dart +++ b/packages/genui_firebase_ai/lib/src/firebase_ai_content_generator.dart @@ -7,10 +7,9 @@ import 'dart:convert'; import 'package:firebase_ai/firebase_ai.dart' hide TextPart; // ignore: implementation_imports -import 'package:firebase_ai/src/api.dart' show ModalityTokenCount; + import 'package:flutter/foundation.dart'; import 'package:genui/genui.dart' hide Part; -import 'package:json_schema_builder/json_schema_builder.dart' as dsb; import 'gemini_content_converter.dart'; import 'gemini_generative_model.dart'; @@ -38,7 +37,6 @@ class FirebaseAiContentGenerator implements ContentGenerator { FirebaseAiContentGenerator({ required this.catalog, this.systemInstruction, - this.outputToolName = 'provideFinalOutput', this.modelCreator = defaultGenerativeModelFactory, this.configuration = const GenUiConfiguration(), this.additionalTools = const [], @@ -52,15 +50,6 @@ class FirebaseAiContentGenerator implements ContentGenerator { /// The system instruction to use for the AI model. final String? systemInstruction; - /// The name of an internal pseudo-tool used to retrieve the final structured - /// output from the AI. - /// - /// This only needs to be provided in case of name collision with another - /// tool. - /// - /// Defaults to 'provideFinalOutput'. - final String outputToolName; - /// A function to use for creating the model itself. /// /// This factory function is responsible for instantiating the @@ -115,15 +104,7 @@ class FirebaseAiContentGenerator implements ContentGenerator { _isProcessing.value = true; try { final messages = [...?history, message]; - final Object? response = await _generate( - messages: messages, - // This turns on forced function calling. - outputSchema: dsb.S.object(properties: {'response': dsb.S.string()}), - ); - // Convert any response to a text response to the user. - if (response is Map && response.containsKey('response')) { - _textResponseController.add(response['response']! as String); - } + await _generate(messages: messages); } catch (e, st) { genUiLogger.severe('Error generating content', e, st); _errorController.add(ContentGeneratorError(e, st)); @@ -154,32 +135,12 @@ class FirebaseAiContentGenerator implements ContentGenerator { ({List? generativeAiTools, Set allowedFunctionNames}) _setupToolsAndFunctions({ - required bool isForcedToolCalling, required List availableTools, required GeminiSchemaAdapter adapter, - required dsb.Schema? outputSchema, }) { - genUiLogger.fine( - 'Setting up tools' - '${isForcedToolCalling ? ' with forced tool calling' : ''}', - ); - // Create an "output" tool that copies its args into the output. - final DynamicAiTool>? finalOutputAiTool = - isForcedToolCalling - ? DynamicAiTool>( - name: outputToolName, - description: - '''Returns the final output. Call this function when you are done with the current turn of the conversation. Do not call this if you need to use other tools first. You MUST call this tool when you are done.''', - // Wrap the outputSchema in an object so that the output schema - // isn't limited to objects. - parameters: dsb.S.object(properties: {'output': outputSchema!}), - invokeFunction: (args) async => args, // Invoke is a pass-through - ) - : null; + genUiLogger.fine('Setting up tools'); - final List> allTools = isForcedToolCalling - ? [...availableTools, finalOutputAiTool!] - : availableTools; + final allTools = availableTools; genUiLogger.fine( 'Available tools: ${allTools.map((t) => t.name).join(', ')}', ); @@ -263,14 +224,9 @@ class FirebaseAiContentGenerator implements ContentGenerator { ); } - Future< - ({List functionResponseParts, Object? capturedResult}) - > - _processFunctionCalls({ + Future> _processFunctionCalls({ required List functionCalls, - required bool isForcedToolCalling, required List availableTools, - Object? capturedResult, }) async { genUiLogger.fine( 'Processing ${functionCalls.length} function calls from model.', @@ -280,25 +236,6 @@ class FirebaseAiContentGenerator implements ContentGenerator { genUiLogger.fine( 'Processing function call: ${call.name} with args: ${call.args}', ); - if (isForcedToolCalling && call.name == outputToolName) { - try { - capturedResult = call.args['output']; - genUiLogger.fine( - 'Captured final output from tool "$outputToolName".', - ); - } catch (exception, stack) { - genUiLogger.severe( - 'Unable to read output: $call [${call.args}]', - exception, - stack, - ); - } - genUiLogger.info( - '****** Gen UI Output ******.\n' - '${const JsonEncoder.withIndent(' ').convert(capturedResult)}', - ); - break; - } final AiTool aiTool = availableTools.firstWhere( (t) => t.name == call.name || t.fullName == call.name, @@ -328,34 +265,14 @@ class FirebaseAiContentGenerator implements ContentGenerator { 'Finished processing function calls. Returning ' '${functionResponseParts.length} responses.', ); - return ( - functionResponseParts: functionResponseParts, - capturedResult: capturedResult, - ); + return functionResponseParts; } - Future _generate({ - required Iterable messages, - dsb.Schema? outputSchema, - }) async { - final isForcedToolCalling = outputSchema != null; + Future _generate({required Iterable messages}) async { final converter = GeminiContentConverter(); final adapter = GeminiSchemaAdapter(); - final List> availableTools = [ - if (configuration.actions.allowCreate || - configuration.actions.allowUpdate) ...[ - SurfaceUpdateTool( - handleMessage: _a2uiMessageController.add, - catalog: catalog, - configuration: configuration, - ), - CreateSurfaceTool(handleMessage: _a2uiMessageController.add), - ], - if (configuration.actions.allowDelete) - DeleteSurfaceTool(handleMessage: _a2uiMessageController.add), - ...additionalTools, - ]; + final List> availableTools = [...additionalTools]; // A local copy of the incoming messages which is updated with tool results // as they are generated. @@ -367,15 +284,12 @@ class FirebaseAiContentGenerator implements ContentGenerator { :List? generativeAiTools, :Set allowedFunctionNames, ) = _setupToolsAndFunctions( - isForcedToolCalling: isForcedToolCalling, availableTools: availableTools, adapter: adapter, - outputSchema: outputSchema, ); var toolUsageCycle = 0; const maxToolUsageCycles = 40; // Safety break for tool loops - Object? capturedResult; final String definition = const JsonEncoder.withIndent( ' ', @@ -385,24 +299,22 @@ class FirebaseAiContentGenerator implements ContentGenerator { systemInstruction: Content.system( '${systemInstruction ?? ''}\n\n' 'You have access to the following UI components:\n' - '$definition', + '$definition\n\n' + 'You must output your response as a stream of JSON objects, one per ' + 'line (JSONL). Each line can be either a plain text response or a ' + 'structured A2UI message (e.g., createSurface, surfaceUpdate). ' + 'Do not wrap the JSON objects in a list or any other structure. ' + 'Just output one JSON object per line.', ), tools: generativeAiTools, - toolConfig: isForcedToolCalling - ? ToolConfig( - functionCallingConfig: FunctionCallingConfig.any( - allowedFunctionNames.toSet(), - ), - ) - : ToolConfig(functionCallingConfig: FunctionCallingConfig.auto()), + toolConfig: ToolConfig( + functionCallingConfig: FunctionCallingConfig.auto(), + ), ); + toolLoop: while (toolUsageCycle < maxToolUsageCycles) { genUiLogger.fine('Starting tool usage cycle ${toolUsageCycle + 1}.'); - if (isForcedToolCalling && capturedResult != null) { - genUiLogger.fine('Captured result found, exiting tool usage loop.'); - break; - } toolUsageCycle++; final String concatenatedContents = mutableContent @@ -416,206 +328,107 @@ With functions: ''', ); final inferenceStartTime = DateTime.now(); - GenerateContentResponse response; - response = await model.generateContent(mutableContent); - genUiLogger.finest('Raw model response: ${_responseToString(response)}'); + // We use generateContentStream to handle streaming responses + final Stream responseStream = model + .generateContentStream(mutableContent); - final Duration elapsed = DateTime.now().difference(inferenceStartTime); - - if (response.usageMetadata != null) { - inputTokenUsage += response.usageMetadata!.promptTokenCount ?? 0; - outputTokenUsage += response.usageMetadata!.candidatesTokenCount ?? 0; - } - genUiLogger.info( - '****** Completed Inference ******\n' - 'Latency = ${elapsed.inMilliseconds}ms\n' - 'Output tokens = ${response.usageMetadata?.candidatesTokenCount ?? 0}\n' - 'Prompt tokens = ${response.usageMetadata?.promptTokenCount ?? 0}', - ); + final currentLineBuffer = StringBuffer(); - if (response.candidates.isEmpty) { - genUiLogger.warning( - 'Response has no candidates: ${response.promptFeedback}', - ); - return isForcedToolCalling ? null : ''; - } + await for (final GenerateContentResponse response in responseStream) { + if (response.candidates.isEmpty) { + continue; + } + final Candidate candidate = response.candidates.first; - final Candidate candidate = response.candidates.first; - final List functionCalls = candidate.content.parts - .whereType() - .toList(); + // Handle function calls if any (though we prefer JSONL now, tools + // might still be used for other things) + final List functionCalls = candidate.content.parts + .whereType() + .toList(); - if (functionCalls.isEmpty) { - genUiLogger.fine('Model response contained no function calls.'); - if (isForcedToolCalling) { - genUiLogger.warning( - 'Model did not call any function. FinishReason: ' - '${candidate.finishReason}. Text: "${candidate.text}" ', - ); - if (candidate.text != null && candidate.text!.trim().isNotEmpty) { - genUiLogger.warning( - 'Model returned direct text instead of a tool call. This might ' - 'be an error or unexpected AI behavior for forced tool calling.', - ); - } + if (functionCalls.isNotEmpty) { genUiLogger.fine( - 'Model returned text but no function calls with forced tool ' - 'calling, so returning null.', + 'Model response contained ${functionCalls.length} function calls.', ); - return null; - } else { - final String text = candidate.text ?? ''; mutableContent.add(candidate.content); - genUiLogger.fine('Returning text response: "$text"'); - _textResponseController.add(text); - return text; + final List functionResponseParts = + await _processFunctionCalls( + functionCalls: functionCalls, + availableTools: availableTools, + ); + + if (functionResponseParts.isNotEmpty) { + mutableContent.add( + Content.functionResponses(functionResponseParts), + ); + genUiLogger.fine( + 'Added tool response message with ' + '${functionResponseParts.length} parts to conversation.', + ); + // Continue the loop to send tool outputs back to the model + continue toolLoop; + } } - } - - genUiLogger.fine( - 'Model response contained ${functionCalls.length} function calls.', - ); - mutableContent.add(candidate.content); - genUiLogger.fine( - 'Added assistant message with ${candidate.content.parts.length} ' - 'parts to conversation.', - ); - final ({ - Object? capturedResult, - List functionResponseParts, - }) - result = await _processFunctionCalls( - functionCalls: functionCalls, - isForcedToolCalling: isForcedToolCalling, - availableTools: availableTools, - capturedResult: capturedResult, - ); - capturedResult = result.capturedResult; - final List functionResponseParts = - result.functionResponseParts; - - if (functionResponseParts.isNotEmpty) { - mutableContent.add(Content.functionResponses(functionResponseParts)); - genUiLogger.fine( - 'Added tool response message with ${functionResponseParts.length} ' - 'parts to conversation.', - ); + // Handle text content for JSONL parsing + final String? text = candidate.text; + if (text != null && text.isNotEmpty) { + for (var i = 0; i < text.length; i++) { + final String char = text[i]; + if (char == '\n') { + _processLine(currentLineBuffer.toString()); + currentLineBuffer.clear(); + } else { + currentLineBuffer.write(char); + } + } + } } - // If the model returned a text response, we assume it's the final - // response and we should stop the tool calling loop. - if (!isForcedToolCalling && - candidate.text != null && - candidate.text!.trim().isNotEmpty) { - genUiLogger.fine( - 'Model returned a text response of "${candidate.text!.trim()}". ' - 'Exiting tool loop.', - ); - _textResponseController.add(candidate.text!); - return candidate.text; + // Process any remaining content in the buffer + if (currentLineBuffer.isNotEmpty) { + _processLine(currentLineBuffer.toString()); } - } - if (isForcedToolCalling) { - if (toolUsageCycle >= maxToolUsageCycles) { - genUiLogger.severe( - 'Error: Tool usage cycle exceeded maximum of $maxToolUsageCycles. ', - 'No final output was produced.', - StackTrace.current, - ); - } - genUiLogger.fine('Exited tool usage loop. Returning captured result.'); - return capturedResult; - } else { - genUiLogger.severe( - 'Error: Tool usage cycle exceeded maximum of $maxToolUsageCycles. ', - 'No final output was produced.', - StackTrace.current, + final Duration elapsed = DateTime.now().difference(inferenceStartTime); + genUiLogger.info( + '****** Completed Inference ******\n' + 'Latency = ${elapsed.inMilliseconds}ms', ); - return ''; + + // If we reached here, it means the stream finished. + // If there were function calls, the loop would have continued via + // `continue`. If there were no function calls, we are done. + break; } } -} -String _usageMetadata(UsageMetadata? metadata) { - if (metadata == null) return ''; - final buffer = StringBuffer(); - buffer.writeln('UsageMetadata('); - buffer.writeln(' promptTokenCount: ${metadata.promptTokenCount},'); - buffer.writeln(' candidatesTokenCount: ${metadata.candidatesTokenCount},'); - buffer.writeln(' totalTokenCount: ${metadata.totalTokenCount},'); - buffer.writeln(' thoughtsTokenCount: ${metadata.thoughtsTokenCount},'); - buffer.writeln( - ' toolUsePromptTokenCount: ${metadata.toolUsePromptTokenCount},', - ); - buffer.writeln(' promptTokensDetails: ['); - for (final ModalityTokenCount detail - in metadata.promptTokensDetails ?? []) { - buffer.writeln(' ModalityTokenCount('); - buffer.writeln(' modality: ${detail.modality},'); - buffer.writeln(' tokenCount: ${detail.tokenCount},'); - buffer.writeln(' ),'); - } - buffer.writeln(' ],'); - buffer.writeln(' candidatesTokensDetails: ['); - for (final ModalityTokenCount detail - in metadata.candidatesTokensDetails ?? []) { - buffer.writeln(' ModalityTokenCount('); - buffer.writeln(' ${detail.modality},'); - buffer.writeln(' ${detail.tokenCount},'); - buffer.writeln(' ),'); - } - buffer.writeln(' ],'); - buffer.writeln(' toolUsePromptTokensDetails: ['); - for (final ModalityTokenCount detail - in metadata.toolUsePromptTokensDetails ?? []) { - buffer.writeln(' ModalityTokenCount('); - buffer.writeln(' ${detail.modality},'); - buffer.writeln(' ${detail.tokenCount},'); - buffer.writeln(' ),'); - } - buffer.writeln(' ],'); - buffer.writeln(')'); - return buffer.toString(); -} + void _processLine(String line) { + line = line.trim(); + if (line.isEmpty) return; -String _responseToString(GenerateContentResponse response) { - final buffer = StringBuffer(); - buffer.writeln('GenerateContentResponse('); - buffer.writeln(' usageMetadata: ${_usageMetadata(response.usageMetadata)},'); - buffer.writeln(' promptFeedback: ${response.promptFeedback},'); - buffer.writeln(' candidates: ['); - for (final Candidate candidate in response.candidates) { - buffer.writeln(' Candidate('); - buffer.writeln(' finishReason: ${candidate.finishReason},'); - buffer.writeln(' finishMessage: "${candidate.finishMessage}",'); - buffer.writeln(' content: Content('); - buffer.writeln(' role: "${candidate.content.role}",'); - buffer.writeln(' parts: ['); - for (final Part part in candidate.content.parts) { - if (part is TextPart) { - buffer.writeln( - ' TextPart(text: "${(part as TextPart).text}"),', - ); - } else if (part is FunctionCall) { - buffer.writeln(' FunctionCall('); - buffer.writeln(' name: "${part.name}",'); - final String indentedLines = (const JsonEncoder.withIndent( - ' ', - ).convert(part.args)).split('\n').join('\n '); - buffer.writeln(' args: $indentedLines,'); - buffer.writeln(' ),'); - } else { - buffer.writeln(' Unknown Part: ${part.runtimeType},'); + try { + final dynamic json = jsonDecode(line); + if (json is Map) { + // Check if it's an A2UI message + // We can try to parse it as an A2uiMessage, or check for specific keys + // Ideally A2uiMessage.fromJson would handle it or throw + try { + final message = A2uiMessage.fromJson(json); + _a2uiMessageController.add(message); + return; + } catch (_) { + // Not an A2UI message, treat as text/other JSON + } } + } catch (_) { + // Not JSON, treat as text } - buffer.writeln(' ],'); - buffer.writeln(' ),'); - buffer.writeln(' ),'); + _textResponseController.add(line); } - buffer.writeln(' ],'); - buffer.writeln(')'); - return buffer.toString(); } + + + + diff --git a/packages/genui_firebase_ai/lib/src/gemini_generative_model.dart b/packages/genui_firebase_ai/lib/src/gemini_generative_model.dart index 1565d195b..91ac82c23 100644 --- a/packages/genui_firebase_ai/lib/src/gemini_generative_model.dart +++ b/packages/genui_firebase_ai/lib/src/gemini_generative_model.dart @@ -11,6 +11,11 @@ import 'package:firebase_ai/firebase_ai.dart'; abstract class GeminiGenerativeModelInterface { /// Generates content from the given [content]. Future generateContent(Iterable content); + + /// Generates a stream of content from the given [content]. + Stream generateContentStream( + Iterable content, + ); } /// A wrapper for the `firebase_ai` [GenerativeModel] that implements the @@ -29,4 +34,11 @@ class GeminiGenerativeModel implements GeminiGenerativeModelInterface { Future generateContent(Iterable content) { return _model.generateContent(content); } + + @override + Stream generateContentStream( + Iterable content, + ) { + return _model.generateContentStream(content); + } } diff --git a/packages/genui_firebase_ai/test/firebase_ai_content_generator_test.dart b/packages/genui_firebase_ai/test/firebase_ai_content_generator_test.dart index d82b50aaa..f1f31b635 100644 --- a/packages/genui_firebase_ai/test/firebase_ai_content_generator_test.dart +++ b/packages/genui_firebase_ai/test/firebase_ai_content_generator_test.dart @@ -22,9 +22,7 @@ void main() { GenerateContentResponse([ Candidate( Content.model([ - const FunctionCall('provideFinalOutput', { - 'output': {'response': 'Hello'}, - }), + const TextPart('{"response": "Hello"}'), ]), [], null, @@ -71,9 +69,7 @@ void main() { GenerateContentResponse([ Candidate( Content.model([ - const FunctionCall('provideFinalOutput', { - 'output': {'response': 'Tool called'}, - }), + const TextPart('Tool called'), ]), [], null, @@ -102,9 +98,7 @@ void main() { GenerateContentResponse([ Candidate( Content.model([ - const FunctionCall('provideFinalOutput', { - 'output': {'response': 'Hello'}, - }), + const TextPart('Hello'), ]), [], null, @@ -134,6 +128,13 @@ class FakeGeminiGenerativeModel implements GeminiGenerativeModelInterface { @override Future generateContent(Iterable content) { - return Future.delayed(Duration.zero, () => responses[callCount++]); + throw UnimplementedError(); + } + + @override + Stream generateContentStream( + Iterable content, + ) async* { + yield responses[callCount++]; } } diff --git a/packages/genui_firebase_ai/test/test_infra/utils.dart b/packages/genui_firebase_ai/test/test_infra/utils.dart index 219537815..be4917732 100644 --- a/packages/genui_firebase_ai/test/test_infra/utils.dart +++ b/packages/genui_firebase_ai/test/test_infra/utils.dart @@ -37,4 +37,28 @@ class FakeGenerativeModel implements GeminiGenerativeModelInterface { 'No response or exception configured for FakeGenerativeModel', ); } + + @override + Stream generateContentStream( + Iterable content, + ) async* { + generateContentCallCount++; + if (exception != null) { + final Exception? e = exception; + exception = null; // Reset for next call + throw e!; + } + if (responses.isNotEmpty) { + final GenerateContentResponse response = responses.removeAt(0); + yield GenerateContentResponse(response.candidates, promptFeedback); + return; + } + if (response != null) { + yield GenerateContentResponse(response!.candidates, promptFeedback); + return; + } + throw StateError( + 'No response or exception configured for FakeGenerativeModel', + ); + } } diff --git a/packages/genui_google_generative_ai/lib/src/google_generative_ai_content_generator.dart b/packages/genui_google_generative_ai/lib/src/google_generative_ai_content_generator.dart index 17560caf3..c3f090e12 100644 --- a/packages/genui_google_generative_ai/lib/src/google_generative_ai_content_generator.dart +++ b/packages/genui_google_generative_ai/lib/src/google_generative_ai_content_generator.dart @@ -10,7 +10,6 @@ import 'package:genui/genui.dart'; import 'package:google_cloud_ai_generativelanguage_v1beta/generativelanguage.dart' as google_ai; import 'package:google_cloud_protobuf/protobuf.dart' as protobuf; -import 'package:json_schema_builder/json_schema_builder.dart' as dsb; import 'google_content_converter.dart'; import 'google_generative_service_interface.dart'; @@ -32,7 +31,6 @@ class GoogleGenerativeAiContentGenerator implements ContentGenerator { GoogleGenerativeAiContentGenerator({ required this.catalog, this.systemInstruction, - this.outputToolName = 'provideFinalOutput', this.serviceFactory = defaultGenerativeServiceFactory, this.configuration = const GenUiConfiguration(), this.additionalTools = const [], @@ -48,15 +46,6 @@ class GoogleGenerativeAiContentGenerator implements ContentGenerator { /// The system instruction to use for the AI model. final String? systemInstruction; - /// The name of an internal pseudo-tool used to retrieve the final structured - /// output from the AI. - /// - /// This only needs to be provided in case of name collision with another - /// tool. - /// - /// Defaults to 'provideFinalOutput'. - final String outputToolName; - /// A function to use for creating the service itself. /// /// This factory function is responsible for instantiating the @@ -117,15 +106,9 @@ class GoogleGenerativeAiContentGenerator implements ContentGenerator { _isProcessing.value = true; try { final messages = [...?history, message]; - final response = await _generate( + await _generate( messages: messages, - // This turns on forced function calling. - outputSchema: dsb.S.object(properties: {'response': dsb.S.string()}), ); - // Convert any response to a text response to the user. - if (response is Map && response.containsKey('response')) { - _textResponseController.add(response['response']! as String); - } } catch (e, st) { genUiLogger.severe('Error generating content', e, st); _errorController.add(ContentGeneratorError(e, st)); @@ -149,31 +132,12 @@ class GoogleGenerativeAiContentGenerator implements ContentGenerator { ({List? tools, Set allowedFunctionNames}) _setupToolsAndFunctions({ - required bool isForcedToolCalling, required List availableTools, required GoogleSchemaAdapter adapter, - required dsb.Schema? outputSchema, }) { - genUiLogger.fine( - 'Setting up tools' - '${isForcedToolCalling ? ' with forced tool calling' : ''}', - ); - // Create an "output" tool that copies its args into the output. - final finalOutputAiTool = isForcedToolCalling - ? DynamicAiTool>( - name: outputToolName, - description: - '''Returns the final output. Call this function when you are done with the current turn of the conversation. Do not call this if you need to use other tools first. You MUST call this tool when you are done.''', - // Wrap the outputSchema in an object so that the output schema - // isn't limited to objects. - parameters: dsb.S.object(properties: {'output': outputSchema!}), - invokeFunction: (args) async => args, // Invoke is a pass-through - ) - : null; + genUiLogger.fine('Setting up tools'); - final allTools = isForcedToolCalling - ? [...availableTools, finalOutputAiTool!] - : availableTools; + final allTools = availableTools; genUiLogger.fine( 'Available tools: ${allTools.map((t) => t.name).join(', ')}', ); @@ -251,12 +215,9 @@ class GoogleGenerativeAiContentGenerator implements ContentGenerator { return (tools: tools, allowedFunctionNames: allowedFunctionNames); } - Future<({List functionResponseParts, Object? capturedResult})> - _processFunctionCalls({ + Future<({List functionResponseParts})> _processFunctionCalls({ required List functionCalls, - required bool isForcedToolCalling, required List availableTools, - Object? capturedResult, }) async { genUiLogger.fine( 'Processing ${functionCalls.length} function calls from model.', @@ -266,27 +227,6 @@ class GoogleGenerativeAiContentGenerator implements ContentGenerator { genUiLogger.fine( 'Processing function call: ${call.name} with args: ${call.args}', ); - if (isForcedToolCalling && call.name == outputToolName) { - try { - // Convert Struct args to Map to extract output - final argsMap = call.args?.toJson() as Map?; - capturedResult = argsMap?['output']; - genUiLogger.fine( - 'Captured final output from tool "$outputToolName".', - ); - } catch (exception, stack) { - genUiLogger.severe( - 'Unable to read output: $call [${call.args}]', - exception, - stack, - ); - } - genUiLogger.info( - '****** Gen UI Output ******.\n' - '${const JsonEncoder.withIndent(' ').convert(capturedResult)}', - ); - break; - } final aiTool = availableTools.firstWhere( (t) => t.name == call.name || t.fullName == call.name, @@ -326,17 +266,12 @@ class GoogleGenerativeAiContentGenerator implements ContentGenerator { 'Finished processing function calls. Returning ' '${functionResponseParts.length} responses.', ); - return ( - functionResponseParts: functionResponseParts, - capturedResult: capturedResult, - ); + return (functionResponseParts: functionResponseParts); } - Future _generate({ + Future _generate({ required Iterable messages, - dsb.Schema? outputSchema, }) async { - final isForcedToolCalling = outputSchema != null; final converter = GoogleContentConverter(); final adapter = GoogleSchemaAdapter(); @@ -364,15 +299,12 @@ class GoogleGenerativeAiContentGenerator implements ContentGenerator { final content = converter.toGoogleAiContent(messages); final (:tools, :allowedFunctionNames) = _setupToolsAndFunctions( - isForcedToolCalling: isForcedToolCalling, availableTools: availableTools, adapter: adapter, - outputSchema: outputSchema, ); var toolUsageCycle = 0; const maxToolUsageCycles = 40; // Safety break for tool loops - Object? capturedResult; // Build system instruction if provided final definition = const JsonEncoder.withIndent( @@ -381,7 +313,12 @@ class GoogleGenerativeAiContentGenerator implements ContentGenerator { final effectiveSystemInstruction = '${systemInstruction ?? ''}\n\n' 'You have access to the following UI components:\n' - '$definition'; + '$definition\n\n' + 'You must output your response as a stream of JSON objects, one per ' + 'line (JSONL). Each line can be either a plain text response or a ' + 'structured A2UI message (e.g., createSurface, surfaceUpdate). ' + 'Do not wrap the JSON objects in a list or any other structure. ' + 'Just output one JSON object per line.'; final systemInstructionContent = [ google_ai.Content( @@ -389,12 +326,9 @@ class GoogleGenerativeAiContentGenerator implements ContentGenerator { ), ]; + toolLoop: while (toolUsageCycle < maxToolUsageCycles) { genUiLogger.fine('Starting tool usage cycle ${toolUsageCycle + 1}.'); - if (isForcedToolCalling && capturedResult != null) { - genUiLogger.fine('Captured result found, exiting tool usage loop.'); - break; - } toolUsageCycle++; final concatenatedContents = content @@ -408,227 +342,127 @@ With functions: ''', ); final inferenceStartTime = DateTime.now(); - google_ai.GenerateContentResponse response; - try { - final request = google_ai.GenerateContentRequest( - model: modelName, - contents: [...systemInstructionContent, ...content], - tools: tools, - toolConfig: isForcedToolCalling - ? google_ai.ToolConfig( - functionCallingConfig: google_ai.FunctionCallingConfig( - mode: google_ai.FunctionCallingConfig_Mode.any, - allowedFunctionNames: allowedFunctionNames.toList(), - ), - ) - : google_ai.ToolConfig( - functionCallingConfig: google_ai.FunctionCallingConfig( - mode: google_ai.FunctionCallingConfig_Mode.auto, - ), - ), - ); - response = await service.generateContent(request); - genUiLogger.finest( - 'Raw model response: ${_responseToString(response)}', - ); - } catch (e, st) { - genUiLogger.severe('Error from service.generateContent', e, st); - _errorController.add(ContentGeneratorError(e, st)); - rethrow; - } - final elapsed = DateTime.now().difference(inferenceStartTime); - if (response.usageMetadata != null) { - inputTokenUsage += (response.usageMetadata!.promptTokenCount ?? 0) - .toInt(); - outputTokenUsage += - (response.usageMetadata!.candidatesTokenCount ?? 0).toInt(); - } - genUiLogger.info( - '****** Completed Inference ******\n' - 'Latency = ${elapsed.inMilliseconds}ms\n' - 'Output tokens = ' - '${response.usageMetadata?.candidatesTokenCount ?? 0}\n' - 'Prompt tokens = ${response.usageMetadata?.promptTokenCount ?? 0}', + final request = google_ai.GenerateContentRequest( + model: modelName, + contents: [...systemInstructionContent, ...content], + tools: tools, + toolConfig: google_ai.ToolConfig( + functionCallingConfig: google_ai.FunctionCallingConfig( + mode: google_ai.FunctionCallingConfig_Mode.auto, + ), + ), ); - if (response.candidates == null || response.candidates!.isEmpty) { - genUiLogger.warning( - 'Response has no candidates: ${response.promptFeedback}', - ); - return isForcedToolCalling ? null : ''; - } + final responseStream = service.streamGenerateContent(request); - final candidate = response.candidates!.first; - final functionCalls = []; - if (candidate.content?.parts != null) { - for (final part in candidate.content!.parts!) { - if (part.functionCall != null) { - functionCalls.add(part.functionCall!); - } + final currentLineBuffer = StringBuffer(); + + await for (final google_ai.GenerateContentResponse response + in responseStream) { + if (response.candidates == null || response.candidates!.isEmpty) { + continue; } - } - if (functionCalls.isEmpty) { - genUiLogger.fine('Model response contained no function calls.'); - if (isForcedToolCalling) { - genUiLogger.warning( - 'Model did not call any function. FinishReason: ' - '${candidate.finishReason}.', - ); - // Extract text from parts - String? text; - if (candidate.content?.parts != null) { - final textParts = candidate.content!.parts! - .where((google_ai.Part p) => p.text != null) - .map((google_ai.Part p) => p.text!) - .toList(); - text = textParts.join(''); - } - if (text != null && text.trim().isNotEmpty) { - genUiLogger.warning( - 'Model returned direct text instead of a tool call. ' - 'This might be an error or unexpected AI behavior for ' - 'forced tool calling.', - ); + final candidate = response.candidates!.first; + + // Handle function calls + final functionCalls = []; + if (candidate.content?.parts != null) { + for (final part in candidate.content!.parts!) { + if (part.functionCall != null) { + functionCalls.add(part.functionCall!); + } } + } + + if (functionCalls.isNotEmpty) { genUiLogger.fine( - 'Model returned text but no function calls with forced tool ' - 'calling, so returning null.', + 'Model response contained ${functionCalls.length} ' + 'function calls.', ); - return null; - } else { - // Extract text from parts - var text = ''; - if (candidate.content?.parts != null) { - final textParts = candidate.content!.parts! - .where((google_ai.Part p) => p.text != null) - .map((google_ai.Part p) => p.text!) - .toList(); - text = textParts.join(''); - } if (candidate.content != null) { content.add(candidate.content!); } - genUiLogger.fine('Returning text response: "$text"'); - _textResponseController.add(text); - return text; - } - } - genUiLogger.fine( - 'Model response contained ${functionCalls.length} function calls.', - ); - if (candidate.content != null) { - content.add(candidate.content!); - } - genUiLogger.fine( - 'Added assistant message with ' - '${candidate.content?.parts?.length ?? 0} ' - 'parts to conversation.', - ); - - final result = await _processFunctionCalls( - functionCalls: functionCalls, - isForcedToolCalling: isForcedToolCalling, - availableTools: availableTools, - capturedResult: capturedResult, - ); - capturedResult = result.capturedResult; - final functionResponseParts = result.functionResponseParts; + final result = await _processFunctionCalls( + functionCalls: functionCalls, + availableTools: availableTools, + ); + final functionResponseParts = result.functionResponseParts; - if (functionResponseParts.isNotEmpty) { - content.add( - google_ai.Content(role: 'user', parts: functionResponseParts), - ); - genUiLogger.fine( - 'Added tool response message with ${functionResponseParts.length} ' - 'parts to conversation.', - ); - } + if (functionResponseParts.isNotEmpty) { + content.add( + google_ai.Content(role: 'user', parts: functionResponseParts), + ); + genUiLogger.fine( + 'Added tool response message with ' + '${functionResponseParts.length} parts to conversation.', + ); + continue toolLoop; + } + } - // If the model returned a text response, we assume it's the final - // response and we should stop the tool calling loop. - if (!isForcedToolCalling && candidate.content?.parts != null) { - final textParts = candidate.content!.parts! - .where((google_ai.Part p) => p.text != null) - .map((google_ai.Part p) => p.text!) - .toList(); - final text = textParts.join(''); - if (text.trim().isNotEmpty) { - genUiLogger.fine( - 'Model returned a text response of "${text.trim()}". ' - 'Exiting tool loop.', - ); - _textResponseController.add(text); - return text; + // Handle text content for JSONL parsing + if (candidate.content?.parts != null) { + for (final part in candidate.content!.parts!) { + final text = part.text; + if (text != null && text.isNotEmpty) { + for (var i = 0; i < text.length; i++) { + final char = text[i]; + if (char == '\n') { + _processLine(currentLineBuffer.toString()); + currentLineBuffer.clear(); + } else { + currentLineBuffer.write(char); + } + } + } + } } } - } - if (isForcedToolCalling) { - if (toolUsageCycle >= maxToolUsageCycles) { - genUiLogger.severe( - 'Error: Tool usage cycle exceeded maximum of $maxToolUsageCycles. ', - 'No final output was produced.', - StackTrace.current, - ); + // Process any remaining content in the buffer + if (currentLineBuffer.isNotEmpty) { + _processLine(currentLineBuffer.toString()); } - genUiLogger.fine('Exited tool usage loop. Returning captured result.'); - return capturedResult; - } else { - genUiLogger.severe( - 'Error: Tool usage cycle exceeded maximum of $maxToolUsageCycles. ', - 'No final output was produced.', - StackTrace.current, + + final elapsed = DateTime.now().difference(inferenceStartTime); + genUiLogger.info( + '****** Completed Inference ******\n' + 'Latency = ${elapsed.inMilliseconds}ms', ); - return ''; + + // If we reached here, it means the stream finished. + // If there were function calls, the loop would have continued via + // `continue toolLoop`. If there were no function calls, we are done. + break; } } finally { service.close(); } } -} -String _responseToString(google_ai.GenerateContentResponse response) { - final buffer = StringBuffer(); - buffer.writeln('GenerateContentResponse('); - buffer.writeln(' usageMetadata: ${response.usageMetadata},'); - buffer.writeln(' promptFeedback: ${response.promptFeedback},'); - buffer.writeln(' candidates: ['); - if (response.candidates != null) { - for (final candidate in response.candidates!) { - buffer.writeln(' Candidate('); - buffer.writeln(' finishReason: ${candidate.finishReason},'); - buffer.writeln(' finishMessage: "${candidate.finishMessage}",'); - buffer.writeln(' content: Content('); - buffer.writeln(' role: "${candidate.content?.role}",'); - buffer.writeln(' parts: ['); - if (candidate.content?.parts != null) { - for (final part in candidate.content!.parts!) { - if (part.text != null) { - buffer.writeln(' Part(text: "${part.text}"),'); - } else if (part.functionCall != null) { - buffer.writeln(' Part(functionCall:'); - buffer.writeln(' FunctionCall('); - buffer.writeln(' name: "${part.functionCall!.name}",'); - final indentedLines = (const JsonEncoder.withIndent(' ').convert( - part.functionCall!.args ?? {}, - )).split('\n').join('\n '); - buffer.writeln(' args: $indentedLines,'); - buffer.writeln(' ),'); - buffer.writeln(' ),'); - } else { - buffer.writeln(' Unknown Part,'); - } + void _processLine(String line) { + line = line.trim(); + if (line.isEmpty) return; + + try { + final json = jsonDecode(line); + if (json is Map) { + try { + final message = A2uiMessage.fromJson(json); + _a2uiMessageController.add(message); + return; + } catch (_) { + // Not an A2UI message, treat as text/other JSON } } - buffer.writeln(' ],'); - buffer.writeln(' ),'); - buffer.writeln(' ),'); + } catch (_) { + // Not JSON, treat as text } + _textResponseController.add(line); } - buffer.writeln(' ],'); - buffer.writeln(')'); - return buffer.toString(); } + + diff --git a/packages/genui_google_generative_ai/lib/src/google_generative_service_interface.dart b/packages/genui_google_generative_ai/lib/src/google_generative_service_interface.dart index 15d1b1311..cf98dd2e3 100644 --- a/packages/genui_google_generative_ai/lib/src/google_generative_service_interface.dart +++ b/packages/genui_google_generative_ai/lib/src/google_generative_service_interface.dart @@ -15,6 +15,11 @@ abstract class GoogleGenerativeServiceInterface { google_ai.GenerateContentRequest request, ); + /// Generates a stream of content from the given [request]. + Stream streamGenerateContent( + google_ai.GenerateContentRequest request, + ); + /// Closes the service and releases any resources. void close(); } @@ -41,6 +46,13 @@ class GoogleGenerativeServiceWrapper return _service.generateContent(request); } + @override + Stream streamGenerateContent( + google_ai.GenerateContentRequest request, + ) { + return _service.streamGenerateContent(request); + } + @override void close() { _service.close(); diff --git a/packages/genui_google_generative_ai/test/google_generative_ai_content_generator_test.dart b/packages/genui_google_generative_ai/test/google_generative_ai_content_generator_test.dart index d0f2caff9..f1466a8f2 100644 --- a/packages/genui_google_generative_ai/test/google_generative_ai_content_generator_test.dart +++ b/packages/genui_google_generative_ai/test/google_generative_ai_content_generator_test.dart @@ -25,31 +25,7 @@ void main() { expect(generator, isNotNull); expect(generator.catalog, catalog); expect(generator.modelName, 'models/gemini-2.5-flash'); - expect(generator.outputToolName, 'provideFinalOutput'); - }); - - test('constructor accepts custom model name', () { - final catalog = const genui.Catalog([]); - - final generator = GoogleGenerativeAiContentGenerator( - catalog: catalog, - modelName: 'models/gemini-2.5-pro', - apiKey: 'test-api-key', - ); - - expect(generator.modelName, 'models/gemini-2.5-pro'); - }); - - test('constructor accepts custom output tool name', () { - final catalog = const genui.Catalog([]); - - final generator = GoogleGenerativeAiContentGenerator( - catalog: catalog, - outputToolName: 'customOutput', - apiKey: 'test-api-key', - ); - - expect(generator.outputToolName, 'customOutput'); + expect(generator.modelName, 'models/gemini-2.5-flash'); }); test('constructor accepts system instruction', () { @@ -142,15 +118,7 @@ void main() { content: google_ai.Content( role: 'model', parts: [ - google_ai.Part( - functionCall: google_ai.FunctionCall( - id: '1', - name: 'provideFinalOutput', - args: protobuf.Struct.fromJson({ - 'output': {'response': 'Hello'}, - }), - ), - ), + google_ai.Part(text: '{"response": "Hello"}'), ], ), finishReason: google_ai.Candidate_FinishReason.stop, @@ -170,7 +138,6 @@ void main() { expect(generator.isProcessing.value, isFalse); }); - // TODO(implementation): This test is timing out, needs investigation test( 'can call a tool and return a result', () async { @@ -196,7 +163,7 @@ void main() { functionCall: google_ai.FunctionCall( id: '1', name: 'testTool', - args: protobuf.Struct.fromJson({}), + args: protobuf.Struct.fromJson({}), ), ), ], @@ -211,15 +178,7 @@ void main() { content: google_ai.Content( role: 'model', parts: [ - google_ai.Part( - functionCall: google_ai.FunctionCall( - id: '2', - name: 'provideFinalOutput', - args: protobuf.Struct.fromJson({ - 'output': {'response': 'Tool called'}, - }), - ), - ), + google_ai.Part(text: 'Tool called'), ], ), finishReason: google_ai.Candidate_FinishReason.stop, @@ -236,8 +195,7 @@ void main() { await generator.sendRequest(hi); final response = await completer.future; expect(response, 'Tool called'); - }, - skip: 'Test is timing out, needs debugging', + }, ); test('returns a simple text response', () async { @@ -251,15 +209,7 @@ void main() { content: google_ai.Content( role: 'model', parts: [ - google_ai.Part( - functionCall: google_ai.FunctionCall( - id: '1', - name: 'provideFinalOutput', - args: protobuf.Struct.fromJson({ - 'output': {'response': 'Hello'}, - }), - ), - ), + google_ai.Part(text: 'Hello'), ], ), finishReason: google_ai.Candidate_FinishReason.stop, @@ -293,6 +243,13 @@ class FakeGoogleGenerativeService implements GoogleGenerativeServiceInterface { return Future.delayed(Duration.zero, () => responses[callCount++]); } + @override + Stream streamGenerateContent( + google_ai.GenerateContentRequest request, + ) async* { + yield responses[callCount++]; + } + @override void close() { // No-op for testing From 38414e617fe409654ff95128d8d0a9d9ae092628 Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Fri, 21 Nov 2025 15:19:35 -0800 Subject: [PATCH 06/11] Works when createSurface comes before updateSurface --- .../catalog_gallery/lib/samples_view.dart | 23 +++------- .../samples/hello_world.sample | 4 +- .../catalog_gallery/test/widget_test.dart | 39 ++++++++++++++++ .../lib/src/catalog/core_widgets/column.dart | 3 ++ .../lib/src/catalog/core_widgets/list.dart | 5 +- .../lib/src/catalog/core_widgets/row.dart | 3 ++ .../catalog/core_widgets/widget_helpers.dart | 8 ++-- .../genui/lib/src/core/genui_manager.dart | 9 +++- .../genui/test/core/genui_manager_test.dart | 46 +++++++++++++++++++ 9 files changed, 115 insertions(+), 25 deletions(-) diff --git a/examples/catalog_gallery/lib/samples_view.dart b/examples/catalog_gallery/lib/samples_view.dart index 5b9e57a85..cc3e9c595 100644 --- a/examples/catalog_gallery/lib/samples_view.dart +++ b/examples/catalog_gallery/lib/samples_view.dart @@ -66,23 +66,12 @@ class _SamplesViewState extends State { }); } } else if (update is SurfaceRemoved) { - if (_surfaceIds.contains(update.surfaceId)) { - setState(() { - final int removeIndex = _surfaceIds.indexOf(update.surfaceId); - _surfaceIds.removeAt(removeIndex); - if (_surfaceIds.isEmpty) { - _currentSurfaceIndex = 0; - } else { - if (_currentSurfaceIndex >= removeIndex && - _currentSurfaceIndex > 0) { - _currentSurfaceIndex--; - } - if (_currentSurfaceIndex >= _surfaceIds.length) { - _currentSurfaceIndex = _surfaceIds.length - 1; - } - } - }); - } + setState(() { + _surfaceIds.remove(update.surfaceId); + if (_currentSurfaceIndex >= _surfaceIds.length) { + _currentSurfaceIndex = 0; + } + }); } }); } diff --git a/examples/catalog_gallery/samples/hello_world.sample b/examples/catalog_gallery/samples/hello_world.sample index e83e3ac13..a4f03565c 100644 --- a/examples/catalog_gallery/samples/hello_world.sample +++ b/examples/catalog_gallery/samples/hello_world.sample @@ -2,5 +2,5 @@ name: Test Sample description: This is a test sample to verify the parser. --- -{"surfaceUpdate": {"surfaceId": "default", "components": [{"id": "text1", "component": {"Text": {"text": {"literalString": "Hello World"}}}}]}} -{"beginRendering": {"surfaceId": "default", "root": "text1"}} +{"surfaceUpdate": {"surfaceId": "default", "components": [{"id": "root", "props": { "component": "Text", "text": {"literalString": "Hello World"}}}]}} +{"createSurface": {"surfaceId": "default"}} diff --git a/examples/catalog_gallery/test/widget_test.dart b/examples/catalog_gallery/test/widget_test.dart index cb5999338..0c9175ef1 100644 --- a/examples/catalog_gallery/test/widget_test.dart +++ b/examples/catalog_gallery/test/widget_test.dart @@ -7,6 +7,7 @@ import 'package:file/memory.dart'; import 'package:file/src/interface/directory.dart'; import 'package:file/src/interface/file.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -42,7 +43,45 @@ description: This is a test sample to verify the parser. await tester.tap(find.text('Samples')); await tester.pumpAndSettle(); + // Verify that the sample file is listed. // Verify that the sample file is listed. expect(find.text('test.sample'), findsOneWidget); }); + + testWidgets('Loads sample with CreateSurface before SurfaceUpdate', ( + WidgetTester tester, + ) async { + final fs = MemoryFileSystem(); + final Directory samplesDir = fs.directory('/samples')..createSync(); + final File sampleFile = samplesDir.childFile('ordered.sample'); + sampleFile.writeAsStringSync(''' +name: Ordered Sample +description: Testing order. +--- +{"createSurface": {"surfaceId": "s1"}} +{"surfaceUpdate": {"surfaceId": "s1", "components": [{"id": "root", "props": {"component": "Text", "text": {"literalString": "Ordered Success"}}}]}} +'''); + + await tester.pumpWidget(Container()); // Clear previous widget tree + await tester.pumpAndSettle(); + await tester.pumpWidget( + CatalogGalleryApp(key: UniqueKey(), samplesDir: samplesDir, fs: fs), + ); + await tester.pumpAndSettle(); + + // Tap on the Samples tab to load the view + await tester.tap(find.text('Samples')); + await tester.pumpAndSettle(); + + // Verify sample is listed + expect(find.text('ordered.sample'), findsOneWidget); + + // Tap on sample + await tester.tap(find.text('ordered.sample')); + await tester.pumpAndSettle(); + + // Verify surface is created and content is shown + expect(find.text('s1'), findsOneWidget); // Surface tab + expect(find.text('Ordered Success'), findsOneWidget); // Content + }); } diff --git a/packages/genui/lib/src/catalog/core_widgets/column.dart b/packages/genui/lib/src/catalog/core_widgets/column.dart index 475dae142..5be17f83e 100644 --- a/packages/genui/lib/src/catalog/core_widgets/column.dart +++ b/packages/genui/lib/src/catalog/core_widgets/column.dart @@ -130,6 +130,9 @@ final column = CatalogItem( ); }, templateListWidgetBuilder: (context, list, componentId, dataBinding) { + if (list is! List) { + return const SizedBox.shrink(); + } return Column( mainAxisAlignment: _parseMainAxisAlignment(columnData.distribution), crossAxisAlignment: _parseCrossAxisAlignment(columnData.alignment), diff --git a/packages/genui/lib/src/catalog/core_widgets/list.dart b/packages/genui/lib/src/catalog/core_widgets/list.dart index c4cdfe234..1e12ffa11 100644 --- a/packages/genui/lib/src/catalog/core_widgets/list.dart +++ b/packages/genui/lib/src/catalog/core_widgets/list.dart @@ -69,7 +69,10 @@ final list = CatalogItem( ); }, templateListWidgetBuilder: - (context, Map data, componentId, dataBinding) { + (context, Object? data, componentId, dataBinding) { + if (data is! Map) { + return const SizedBox.shrink(); + } final List values = data.values.toList(); final List keys = data.keys.toList(); return ListView.builder( diff --git a/packages/genui/lib/src/catalog/core_widgets/row.dart b/packages/genui/lib/src/catalog/core_widgets/row.dart index cd8ba003a..12fc7a960 100644 --- a/packages/genui/lib/src/catalog/core_widgets/row.dart +++ b/packages/genui/lib/src/catalog/core_widgets/row.dart @@ -130,6 +130,9 @@ final row = CatalogItem( ); }, templateListWidgetBuilder: (context, list, componentId, dataBinding) { + if (list is! List) { + return const SizedBox.shrink(); + } return Row( mainAxisAlignment: _parseMainAxisAlignment(rowData.distribution), crossAxisAlignment: _parseCrossAxisAlignment(rowData.alignment), diff --git a/packages/genui/lib/src/catalog/core_widgets/widget_helpers.dart b/packages/genui/lib/src/catalog/core_widgets/widget_helpers.dart index 5b18ce40e..7b1a0ad8c 100644 --- a/packages/genui/lib/src/catalog/core_widgets/widget_helpers.dart +++ b/packages/genui/lib/src/catalog/core_widgets/widget_helpers.dart @@ -17,7 +17,7 @@ import '../../primitives/simple_items.dart'; typedef TemplateListWidgetBuilder = Widget Function( BuildContext context, - Map data, + Object? data, String componentId, String dataBinding, ); @@ -101,9 +101,9 @@ class ComponentChildrenBuilder extends StatelessWidget { genUiLogger.finest( 'Widget $componentId subscribing to ${dataContext.path}', ); - final ValueNotifier?> dataNotifier = dataContext - .subscribe>(DataPath(dataBinding)); - return ValueListenableBuilder?>( + final ValueNotifier dataNotifier = dataContext + .subscribe(DataPath(dataBinding)); + return ValueListenableBuilder( valueListenable: dataNotifier, builder: (context, data, child) { genUiLogger.info( diff --git a/packages/genui/lib/src/core/genui_manager.dart b/packages/genui/lib/src/core/genui_manager.dart index db08fa50e..3fa137571 100644 --- a/packages/genui/lib/src/core/genui_manager.dart +++ b/packages/genui/lib/src/core/genui_manager.dart @@ -188,6 +188,7 @@ class GenUiManager implements GenUiHost { final ValueNotifier notifier = getSurfaceNotifier( message.surfaceId, ); + final isNew = notifier.value == null; final UiDefinition uiDefinition = notifier.value ?? UiDefinition(surfaceId: message.surfaceId); final UiDefinition newUiDefinition = uiDefinition.copyWith( @@ -195,7 +196,13 @@ class GenUiManager implements GenUiHost { ); notifier.value = newUiDefinition; genUiLogger.info('Created surface ${message.surfaceId}'); - _surfaceUpdates.add(SurfaceUpdated(message.surfaceId, newUiDefinition)); + if (isNew) { + _surfaceUpdates.add(SurfaceAdded(message.surfaceId, newUiDefinition)); + } else { + _surfaceUpdates.add( + SurfaceUpdated(message.surfaceId, newUiDefinition), + ); + } case DataModelUpdate(): final String path = message.path ?? '/'; genUiLogger.info( diff --git a/packages/genui/test/core/genui_manager_test.dart b/packages/genui/test/core/genui_manager_test.dart index 2f27502bc..fd50727da 100644 --- a/packages/genui/test/core/genui_manager_test.dart +++ b/packages/genui/test/core/genui_manager_test.dart @@ -65,6 +65,52 @@ void main() { // expect(manager.surfaces[surfaceId]!.value!.rootComponentId, 'root'); }); + test( + 'handleMessage fires SurfaceAdded when CreateSurface is received for a ' + 'new surface', + () async { + const surfaceId = 'newSurface'; + final Future futureUpdate = manager.surfaceUpdates.first; + manager.handleMessage(const CreateSurface(surfaceId: surfaceId)); + final GenUiUpdate update = await futureUpdate; + + expect(update, isA()); + expect(update.surfaceId, surfaceId); + }, + ); + + test( + 'handleMessage updates surface when SurfaceUpdate follows CreateSurface', + () async { + const surfaceId = 's2'; + // 1. CreateSurface + final Future futureCreate = manager.surfaceUpdates.first; + manager.handleMessage(const CreateSurface(surfaceId: surfaceId)); + final GenUiUpdate createUpdate = await futureCreate; + expect(createUpdate, isA()); + + // 2. SurfaceUpdate + final components = [ + const Component( + id: 'root', + props: { + 'Text': {'text': 'Updated'}, + }, + ), + ]; + final Future futureUpdate = manager.surfaceUpdates.first; + manager.handleMessage( + SurfaceUpdate(surfaceId: surfaceId, components: components), + ); + final GenUiUpdate update = await futureUpdate; + + expect(update, isA()); + expect(update.surfaceId, surfaceId); + final UiDefinition definition = (update as SurfaceUpdated).definition; + expect(definition.components['root'], components[0]); + }, + ); + test( 'handleMessage updates an existing surface and fires SurfaceUpdated', () async { From 65d3c336ed2769d99609ee8299f8e35882b52373 Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Fri, 21 Nov 2025 15:44:09 -0800 Subject: [PATCH 07/11] Fix slider values --- .../catalog_gallery/lib/samples_view.dart | 4 +- .../travel_app/macos/Runner/AppDelegate.swift | 4 + .../macos/Runner/MainFlutterWindow.swift | 4 + .../macos/RunnerTests/RunnerTests.swift | 4 + .../test/widgets/conversation_test.dart | 8 +- .../lib/src/catalog/core_widgets/slider.dart | 57 +++++---- .../genui/lib/src/core/genui_surface.dart | 4 +- packages/genui/lib/src/core/ui_tools.dart | 4 +- .../development_utilities/catalog_view.dart | 4 +- .../facade/direct_call_integration/utils.dart | 4 +- .../genui/lib/src/model/a2ui_message.dart | 5 +- packages/genui/lib/src/model/ui_models.dart | 23 +--- .../catalog/core_widgets/button_test.dart | 6 +- .../test/catalog/core_widgets/card_test.dart | 9 +- .../catalog/core_widgets/check_box_test.dart | 6 +- .../catalog/core_widgets/column_test.dart | 12 +- .../core_widgets/date_time_input_test.dart | 6 +- .../catalog/core_widgets/divider_test.dart | 9 +- .../test/catalog/core_widgets/icon_test.dart | 12 +- .../test/catalog/core_widgets/list_test.dart | 6 +- .../test/catalog/core_widgets/modal_test.dart | 8 +- .../core_widgets/multiple_choice_test.dart | 6 +- .../test/catalog/core_widgets/row_test.dart | 12 +- .../catalog/core_widgets/slider_test.dart | 65 +++++++++- .../test/catalog/core_widgets/tabs_test.dart | 16 +-- .../test/catalog/core_widgets/text_test.dart | 6 +- .../genui/test/catalog/core_widgets_test.dart | 4 +- packages/genui/test/catalog_test.dart | 4 +- .../genui/test/core/genui_manager_test.dart | 4 +- packages/genui/test/core/ui_tools_test.dart | 4 +- packages/genui/test/genui_surface_test.dart | 8 +- packages/genui/test/ui_tools_test.dart | 11 +- .../test/a2ui_content_generator_test.dart | 3 +- .../src/firebase_ai_content_generator.dart | 4 - .../firebase_ai_content_generator_test.dart | 12 +- ...oogle_generative_ai_content_generator.dart | 10 +- ..._generative_ai_content_generator_test.dart | 113 ++++++++---------- 37 files changed, 229 insertions(+), 252 deletions(-) diff --git a/examples/catalog_gallery/lib/samples_view.dart b/examples/catalog_gallery/lib/samples_view.dart index cc3e9c595..8e025779f 100644 --- a/examples/catalog_gallery/lib/samples_view.dart +++ b/examples/catalog_gallery/lib/samples_view.dart @@ -121,9 +121,7 @@ class _SamplesViewState extends State { ); } catch (exception, stackTrace) { print('Error parsing sample: $exception\n$stackTrace'); - ScaffoldMessenger.of( - context, - ).showSnackBar( + ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Error parsing sample: $exception')), ); } diff --git a/examples/travel_app/macos/Runner/AppDelegate.swift b/examples/travel_app/macos/Runner/AppDelegate.swift index b3c176141..43bd41192 100644 --- a/examples/travel_app/macos/Runner/AppDelegate.swift +++ b/examples/travel_app/macos/Runner/AppDelegate.swift @@ -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. + import Cocoa import FlutterMacOS diff --git a/examples/travel_app/macos/Runner/MainFlutterWindow.swift b/examples/travel_app/macos/Runner/MainFlutterWindow.swift index 3cc05eb23..79861d1c4 100644 --- a/examples/travel_app/macos/Runner/MainFlutterWindow.swift +++ b/examples/travel_app/macos/Runner/MainFlutterWindow.swift @@ -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. + import Cocoa import FlutterMacOS diff --git a/examples/travel_app/macos/RunnerTests/RunnerTests.swift b/examples/travel_app/macos/RunnerTests/RunnerTests.swift index 61f3bd1fc..8b03e329d 100644 --- a/examples/travel_app/macos/RunnerTests/RunnerTests.swift +++ b/examples/travel_app/macos/RunnerTests/RunnerTests.swift @@ -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. + import Cocoa import FlutterMacOS import XCTest diff --git a/examples/travel_app/test/widgets/conversation_test.dart b/examples/travel_app/test/widgets/conversation_test.dart index 04bf7679a..6b9512788 100644 --- a/examples/travel_app/test/widgets/conversation_test.dart +++ b/examples/travel_app/test/widgets/conversation_test.dart @@ -37,9 +37,7 @@ void main() { manager.handleMessage( SurfaceUpdate(surfaceId: surfaceId, components: components), ); - manager.handleMessage( - const CreateSurface(surfaceId: surfaceId), - ); + manager.handleMessage(const CreateSurface(surfaceId: surfaceId)); await tester.pumpWidget( MaterialApp( @@ -89,9 +87,7 @@ void main() { manager.handleMessage( SurfaceUpdate(surfaceId: surfaceId, components: components), ); - manager.handleMessage( - const CreateSurface(surfaceId: surfaceId), - ); + manager.handleMessage(const CreateSurface(surfaceId: surfaceId)); await tester.pumpWidget( MaterialApp( home: Scaffold( diff --git a/packages/genui/lib/src/catalog/core_widgets/slider.dart b/packages/genui/lib/src/catalog/core_widgets/slider.dart index 47b2f9e71..5e42ce7f9 100644 --- a/packages/genui/lib/src/catalog/core_widgets/slider.dart +++ b/packages/genui/lib/src/catalog/core_widgets/slider.dart @@ -14,8 +14,8 @@ import '../../primitives/simple_items.dart'; final _schema = S.object( properties: { 'value': A2uiSchemas.numberReference(), - 'minValue': S.number(), - 'maxValue': S.number(), + 'minValue': A2uiSchemas.numberReference(), + 'maxValue': A2uiSchemas.numberReference(), }, required: ['value'], ); @@ -23,8 +23,8 @@ final _schema = S.object( extension type _SliderData.fromMap(JsonMap _json) { factory _SliderData({ required JsonMap value, - double? minValue, - double? maxValue, + JsonMap? minValue, + JsonMap? maxValue, }) => _SliderData.fromMap({ 'value': value, 'minValue': minValue, @@ -32,8 +32,8 @@ extension type _SliderData.fromMap(JsonMap _json) { }); JsonMap get value => _json['value'] as JsonMap; - double get minValue => (_json['minValue'] as num?)?.toDouble() ?? 0.0; - double get maxValue => (_json['maxValue'] as num?)?.toDouble() ?? 1.0; + JsonMap? get minValue => _json['minValue'] as JsonMap?; + JsonMap? get maxValue => _json['maxValue'] as JsonMap?; } /// A catalog item representing a Material Design slider. @@ -54,10 +54,29 @@ final slider = CatalogItem( final sliderData = _SliderData.fromMap(itemContext.data as JsonMap); final ValueNotifier valueNotifier = itemContext.dataContext .subscribeToValue(sliderData.value, 'literalNumber'); + final ValueNotifier minNotifier = itemContext.dataContext + .subscribeToValue( + sliderData.minValue ?? {'literalNumber': 0.0}, + 'literalNumber', + ); + final ValueNotifier maxNotifier = itemContext.dataContext + .subscribeToValue( + sliderData.maxValue ?? {'literalNumber': 1.0}, + 'literalNumber', + ); + + return ListenableBuilder( + listenable: Listenable.merge([valueNotifier, minNotifier, maxNotifier]), + builder: (context, child) { + final double min = (minNotifier.value ?? 0.0).toDouble(); + final double max = (maxNotifier.value ?? 1.0).toDouble(); + // Ensure min < max to avoid errors + final effectiveMin = min; + final double effectiveMax = max > min ? max : min + 1.0; + + final double val = (valueNotifier.value ?? effectiveMin).toDouble(); + final double effectiveVal = val.clamp(effectiveMin, effectiveMax); - return ValueListenableBuilder( - valueListenable: valueNotifier, - builder: (context, value, child) { return Padding( padding: const EdgeInsetsDirectional.only(end: 16.0), child: Row( @@ -65,11 +84,12 @@ final slider = CatalogItem( children: [ Expanded( child: Slider( - value: (value ?? sliderData.minValue).toDouble(), - min: sliderData.minValue, - max: sliderData.maxValue, - divisions: (sliderData.maxValue - sliderData.minValue) - .toInt(), + value: effectiveVal, + min: effectiveMin, + max: effectiveMax, + divisions: (effectiveMax - effectiveMin) > 0 + ? (effectiveMax - effectiveMin).toInt() + : 1, onChanged: (newValue) { final path = sliderData.value['path'] as String?; if (path != null) { @@ -78,10 +98,7 @@ final slider = CatalogItem( }, ), ), - Text( - value?.toStringAsFixed(0) ?? - sliderData.minValue.toStringAsFixed(0), - ), + Text(effectiveVal.toStringAsFixed(0)), ], ), ); @@ -95,8 +112,8 @@ final slider = CatalogItem( "id": "root", "props": { "component": "Slider", - "minValue": 0, - "maxValue": 10, + "minValue": {"literalNumber": 0}, + "maxValue": {"literalNumber": 10}, "value": { "path": "/myValue", "literalNumber": 5 diff --git a/packages/genui/lib/src/core/genui_surface.dart b/packages/genui/lib/src/core/genui_surface.dart index 9b1dc00ac..95e0d385f 100644 --- a/packages/genui/lib/src/core/genui_surface.dart +++ b/packages/genui/lib/src/core/genui_surface.dart @@ -109,9 +109,7 @@ class _GenUiSurfaceState extends State { final modalId = event.context['modalId'] as String; final Component? modalComponent = definition.components[modalId]; if (modalComponent == null) return; - final contentChildId = - (modalComponent.props['Modal'] as Map)['contentChild'] - as String; + final contentChildId = modalComponent.props['contentChild'] as String; showModalBottomSheet( context: context, builder: (context) => _buildWidget( diff --git a/packages/genui/lib/src/core/ui_tools.dart b/packages/genui/lib/src/core/ui_tools.dart index afa52eb7e..42b2a088b 100644 --- a/packages/genui/lib/src/core/ui_tools.dart +++ b/packages/genui/lib/src/core/ui_tools.dart @@ -103,8 +103,6 @@ class CreateSurfaceTool extends AiTool { final surfaceId = args[surfaceIdKey] as String; final theme = args['theme'] as JsonMap?; handleMessage(CreateSurface(surfaceId: surfaceId, theme: theme)); - return { - 'status': 'Surface $surfaceId created.', - }; + return {'status': 'Surface $surfaceId created.'}; } } diff --git a/packages/genui/lib/src/development_utilities/catalog_view.dart b/packages/genui/lib/src/development_utilities/catalog_view.dart index edc3a2465..6003ffb3a 100644 --- a/packages/genui/lib/src/development_utilities/catalog_view.dart +++ b/packages/genui/lib/src/development_utilities/catalog_view.dart @@ -87,9 +87,7 @@ class _DebugCatalogViewState extends State { _genUi.handleMessage( SurfaceUpdate(surfaceId: surfaceId, components: components), ); - _genUi.handleMessage( - CreateSurface(surfaceId: surfaceId), - ); + _genUi.handleMessage(CreateSurface(surfaceId: surfaceId)); surfaceIds.add(surfaceId); } } diff --git a/packages/genui/lib/src/facade/direct_call_integration/utils.dart b/packages/genui/lib/src/facade/direct_call_integration/utils.dart index 5f449b35a..06fede4e9 100644 --- a/packages/genui/lib/src/facade/direct_call_integration/utils.dart +++ b/packages/genui/lib/src/facade/direct_call_integration/utils.dart @@ -50,9 +50,7 @@ ParsedToolCall parseToolCall(ToolCall toolCall, String toolName) { final surfaceId = (toolCall.args as JsonMap)[surfaceIdKey] as String; - final createSurfaceMessage = CreateSurface( - surfaceId: surfaceId, - ); + final createSurfaceMessage = CreateSurface(surfaceId: surfaceId); return ParsedToolCall( messages: [surfaceUpdateMessage, createSurfaceMessage], diff --git a/packages/genui/lib/src/model/a2ui_message.dart b/packages/genui/lib/src/model/a2ui_message.dart index df558cb9e..dbf2bf1be 100644 --- a/packages/genui/lib/src/model/a2ui_message.dart +++ b/packages/genui/lib/src/model/a2ui_message.dart @@ -109,10 +109,7 @@ final class DataModelUpdate extends A2uiMessage { /// An A2UI message that signals the client to begin rendering. final class CreateSurface extends A2uiMessage { /// Creates a [CreateSurface] message. - const CreateSurface({ - required this.surfaceId, - this.theme, - }); + const CreateSurface({required this.surfaceId, this.theme}); /// Creates a [CreateSurface] message from a JSON map. factory CreateSurface.fromJson(JsonMap json) { diff --git a/packages/genui/lib/src/model/ui_models.dart b/packages/genui/lib/src/model/ui_models.dart index 5706c90ce..56f9c9c22 100644 --- a/packages/genui/lib/src/model/ui_models.dart +++ b/packages/genui/lib/src/model/ui_models.dart @@ -134,11 +134,7 @@ class UiDefinition { /// A component in the UI. final class Component { /// Creates a [Component]. - const Component({ - required this.id, - required this.props, - this.weight, - }); + const Component({required this.id, required this.props, this.weight}); /// Creates a [Component] from a JSON map. factory Component.fromJson(JsonMap json) { @@ -163,11 +159,7 @@ final class Component { /// Converts this object to a JSON map. JsonMap toJson() { - return { - 'id': id, - 'props': props, - if (weight != null) 'weight': weight, - }; + return {'id': id, 'props': props, if (weight != null) 'weight': weight}; } /// The type of the component. @@ -178,14 +170,9 @@ final class Component { other is Component && id == other.id && weight == other.weight && - const DeepCollectionEquality().equals( - props, other.props, - ); + const DeepCollectionEquality().equals(props, other.props); @override - int get hashCode => Object.hash( - id, - weight, - const DeepCollectionEquality().hash(props), - ); + int get hashCode => + Object.hash(id, weight, const DeepCollectionEquality().hash(props)); } diff --git a/packages/genui/test/catalog/core_widgets/button_test.dart b/packages/genui/test/catalog/core_widgets/button_test.dart index 25b25ecb7..3ac32bf94 100644 --- a/packages/genui/test/catalog/core_widgets/button_test.dart +++ b/packages/genui/test/catalog/core_widgets/button_test.dart @@ -19,7 +19,7 @@ void main() { const surfaceId = 'testSurface'; final components = [ const Component( - id: 'button', + id: 'root', props: { 'component': 'Button', 'child': 'button_text', @@ -37,9 +37,7 @@ void main() { manager.handleMessage( SurfaceUpdate(surfaceId: surfaceId, components: components), ); - manager.handleMessage( - const CreateSurface(surfaceId: surfaceId), - ); + manager.handleMessage(const CreateSurface(surfaceId: surfaceId)); await tester.pumpWidget( MaterialApp( diff --git a/packages/genui/test/catalog/core_widgets/card_test.dart b/packages/genui/test/catalog/core_widgets/card_test.dart index 5377ca0ad..953edad02 100644 --- a/packages/genui/test/catalog/core_widgets/card_test.dart +++ b/packages/genui/test/catalog/core_widgets/card_test.dart @@ -15,9 +15,8 @@ void main() { const surfaceId = 'testSurface'; final components = [ const Component( - id: 'card', - props: {'component': 'Card', 'child': 'text', - }, + id: 'root', + props: {'component': 'Card', 'child': 'text'}, ), const Component( id: 'text', @@ -30,9 +29,7 @@ void main() { manager.handleMessage( SurfaceUpdate(surfaceId: surfaceId, components: components), ); - manager.handleMessage( - const CreateSurface(surfaceId: surfaceId), - ); + manager.handleMessage(const CreateSurface(surfaceId: surfaceId)); await tester.pumpWidget( MaterialApp( 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 03e075b3a..7c08d5fe2 100644 --- a/packages/genui/test/catalog/core_widgets/check_box_test.dart +++ b/packages/genui/test/catalog/core_widgets/check_box_test.dart @@ -17,7 +17,7 @@ void main() { const surfaceId = 'testSurface'; final components = [ const Component( - id: 'checkbox', + id: 'root', props: { 'component': 'CheckBox', 'label': {'literalString': 'Check me'}, @@ -28,9 +28,7 @@ void main() { manager.handleMessage( SurfaceUpdate(surfaceId: surfaceId, components: components), ); - manager.handleMessage( - const CreateSurface(surfaceId: surfaceId), - ); + manager.handleMessage(const CreateSurface(surfaceId: surfaceId)); manager.dataModelForSurface(surfaceId).update(DataPath('/myValue'), true); await tester.pumpWidget( diff --git a/packages/genui/test/catalog/core_widgets/column_test.dart b/packages/genui/test/catalog/core_widgets/column_test.dart index ef19848d1..b52916819 100644 --- a/packages/genui/test/catalog/core_widgets/column_test.dart +++ b/packages/genui/test/catalog/core_widgets/column_test.dart @@ -15,7 +15,7 @@ void main() { const surfaceId = 'testSurface'; final components = [ const Component( - id: 'column', + id: 'root', props: { 'component': 'Column', 'children': { @@ -41,9 +41,7 @@ void main() { manager.handleMessage( SurfaceUpdate(surfaceId: surfaceId, components: components), ); - manager.handleMessage( - const CreateSurface(surfaceId: surfaceId), - ); + manager.handleMessage(const CreateSurface(surfaceId: surfaceId)); await tester.pumpWidget( MaterialApp( @@ -67,7 +65,7 @@ void main() { const surfaceId = 'testSurface'; final components = [ const Component( - id: 'column', + id: 'root', props: { 'component': 'Column', 'children': { @@ -103,9 +101,7 @@ void main() { manager.handleMessage( SurfaceUpdate(surfaceId: surfaceId, components: components), ); - manager.handleMessage( - const CreateSurface(surfaceId: surfaceId), - ); + manager.handleMessage(const CreateSurface(surfaceId: surfaceId)); await tester.pumpWidget( MaterialApp( 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 7997734c7..f73387f80 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 @@ -17,7 +17,7 @@ void main() { const surfaceId = 'testSurface'; final components = [ const Component( - id: 'datetime', + id: 'root', props: { 'component': 'DateTimeInput', 'value': {'path': '/myDateTime'}, @@ -27,9 +27,7 @@ void main() { manager.handleMessage( SurfaceUpdate(surfaceId: surfaceId, components: components), ); - manager.handleMessage( - const CreateSurface(surfaceId: surfaceId), - ); + manager.handleMessage(const CreateSurface(surfaceId: surfaceId)); manager .dataModelForSurface(surfaceId) .update(DataPath('/myDateTime'), '2025-10-15'); diff --git a/packages/genui/test/catalog/core_widgets/divider_test.dart b/packages/genui/test/catalog/core_widgets/divider_test.dart index 0359193f2..c3ab59a87 100644 --- a/packages/genui/test/catalog/core_widgets/divider_test.dart +++ b/packages/genui/test/catalog/core_widgets/divider_test.dart @@ -14,17 +14,12 @@ void main() { ); const surfaceId = 'testSurface'; final components = [ - const Component( - id: 'divider', - props: {'component': 'Divider'}, - ), + const Component(id: 'root', props: {'component': 'Divider'}), ]; manager.handleMessage( SurfaceUpdate(surfaceId: surfaceId, components: components), ); - manager.handleMessage( - const CreateSurface(surfaceId: surfaceId), - ); + manager.handleMessage(const CreateSurface(surfaceId: surfaceId)); await tester.pumpWidget( MaterialApp( diff --git a/packages/genui/test/catalog/core_widgets/icon_test.dart b/packages/genui/test/catalog/core_widgets/icon_test.dart index 1f3cda5a7..df53dd10a 100644 --- a/packages/genui/test/catalog/core_widgets/icon_test.dart +++ b/packages/genui/test/catalog/core_widgets/icon_test.dart @@ -17,7 +17,7 @@ void main() { const surfaceId = 'testSurface'; final components = [ const Component( - id: 'icon', + id: 'root', props: { 'component': 'Icon', 'name': {'literalString': 'add'}, @@ -27,9 +27,7 @@ void main() { manager.handleMessage( SurfaceUpdate(surfaceId: surfaceId, components: components), ); - manager.handleMessage( - const CreateSurface(surfaceId: surfaceId), - ); + manager.handleMessage(const CreateSurface(surfaceId: surfaceId)); await tester.pumpWidget( MaterialApp( @@ -52,7 +50,7 @@ void main() { const surfaceId = 'testSurface'; final components = [ const Component( - id: 'icon', + id: 'root', props: { 'component': 'Icon', 'name': {'path': '/iconName'}, @@ -69,9 +67,7 @@ void main() { contents: 'close', ), ); - manager.handleMessage( - const CreateSurface(surfaceId: surfaceId), - ); + manager.handleMessage(const CreateSurface(surfaceId: surfaceId)); await tester.pumpWidget( MaterialApp( diff --git a/packages/genui/test/catalog/core_widgets/list_test.dart b/packages/genui/test/catalog/core_widgets/list_test.dart index 2416d5e88..4a21a6950 100644 --- a/packages/genui/test/catalog/core_widgets/list_test.dart +++ b/packages/genui/test/catalog/core_widgets/list_test.dart @@ -15,7 +15,7 @@ void main() { const surfaceId = 'testSurface'; final components = [ const Component( - id: 'list', + id: 'root', props: { 'component': 'List', 'children': { @@ -41,9 +41,7 @@ void main() { manager.handleMessage( SurfaceUpdate(surfaceId: surfaceId, components: components), ); - manager.handleMessage( - const CreateSurface(surfaceId: surfaceId), - ); + manager.handleMessage(const CreateSurface(surfaceId: surfaceId)); await tester.pumpWidget( MaterialApp( diff --git a/packages/genui/test/catalog/core_widgets/modal_test.dart b/packages/genui/test/catalog/core_widgets/modal_test.dart index fbebfd2dc..f179895fb 100644 --- a/packages/genui/test/catalog/core_widgets/modal_test.dart +++ b/packages/genui/test/catalog/core_widgets/modal_test.dart @@ -21,7 +21,7 @@ void main() { const surfaceId = 'testSurface'; final components = [ const Component( - id: 'modal', + id: 'root', props: { 'component': 'Modal', 'entryPointChild': 'button', @@ -38,7 +38,7 @@ void main() { 'context': [ { 'key': 'modalId', - 'value': {'literalString': 'modal'}, + 'value': {'literalString': 'root'}, }, ], }, @@ -62,9 +62,7 @@ void main() { manager.handleMessage( SurfaceUpdate(surfaceId: surfaceId, components: components), ); - manager.handleMessage( - const CreateSurface(surfaceId: surfaceId), - ); + manager.handleMessage(const CreateSurface(surfaceId: surfaceId)); await tester.pumpWidget( MaterialApp( 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 79b3e9199..20902c3b8 100644 --- a/packages/genui/test/catalog/core_widgets/multiple_choice_test.dart +++ b/packages/genui/test/catalog/core_widgets/multiple_choice_test.dart @@ -17,7 +17,7 @@ void main() { const surfaceId = 'testSurface'; final components = [ const Component( - id: 'multiple_choice', + id: 'root', props: { 'component': 'MultipleChoice', 'selections': {'path': '/mySelections'}, @@ -37,9 +37,7 @@ void main() { manager.handleMessage( SurfaceUpdate(surfaceId: surfaceId, components: components), ); - manager.handleMessage( - const CreateSurface(surfaceId: surfaceId), - ); + manager.handleMessage(const CreateSurface(surfaceId: surfaceId)); manager.dataModelForSurface(surfaceId).update(DataPath('/mySelections'), [ '1', ]); diff --git a/packages/genui/test/catalog/core_widgets/row_test.dart b/packages/genui/test/catalog/core_widgets/row_test.dart index 5baf986ef..c1cee4ef1 100644 --- a/packages/genui/test/catalog/core_widgets/row_test.dart +++ b/packages/genui/test/catalog/core_widgets/row_test.dart @@ -15,7 +15,7 @@ void main() { const surfaceId = 'testSurface'; final components = [ const Component( - id: 'row', + id: 'root', props: { 'component': 'Row', 'children': { @@ -41,9 +41,7 @@ void main() { manager.handleMessage( SurfaceUpdate(surfaceId: surfaceId, components: components), ); - manager.handleMessage( - const CreateSurface(surfaceId: surfaceId), - ); + manager.handleMessage(const CreateSurface(surfaceId: surfaceId)); await tester.pumpWidget( MaterialApp( @@ -67,7 +65,7 @@ void main() { const surfaceId = 'testSurface'; final components = [ const Component( - id: 'row', + id: 'root', props: { 'component': 'Row', 'children': { @@ -102,9 +100,7 @@ void main() { manager.handleMessage( SurfaceUpdate(surfaceId: surfaceId, components: components), ); - manager.handleMessage( - const CreateSurface(surfaceId: surfaceId), - ); + manager.handleMessage(const CreateSurface(surfaceId: surfaceId)); await tester.pumpWidget( MaterialApp( diff --git a/packages/genui/test/catalog/core_widgets/slider_test.dart b/packages/genui/test/catalog/core_widgets/slider_test.dart index 1d8714449..a17c4f8e6 100644 --- a/packages/genui/test/catalog/core_widgets/slider_test.dart +++ b/packages/genui/test/catalog/core_widgets/slider_test.dart @@ -17,19 +17,19 @@ void main() { const surfaceId = 'testSurface'; final components = [ const Component( - id: 'slider', + id: 'root', props: { 'component': 'Slider', 'value': {'path': '/myValue'}, + 'minValue': {'literalNumber': 0.0}, + 'maxValue': {'literalNumber': 1.0}, }, ), ]; manager.handleMessage( SurfaceUpdate(surfaceId: surfaceId, components: components), ); - manager.handleMessage( - const CreateSurface(surfaceId: surfaceId), - ); + manager.handleMessage(const CreateSurface(surfaceId: surfaceId)); manager.dataModelForSurface(surfaceId).update(DataPath('/myValue'), 0.5); await tester.pumpWidget( @@ -51,4 +51,61 @@ void main() { greaterThan(0.5), ); }); + + testWidgets('Slider widget handles data-bound min/max values', ( + WidgetTester tester, + ) async { + final manager = GenUiManager( + catalog: Catalog([CoreCatalogItems.slider]), + configuration: const GenUiConfiguration(), + ); + const surfaceId = 'testSurface'; + final components = [ + const Component( + id: 'root', + props: { + 'component': 'Slider', + 'value': {'path': '/myValue'}, + 'minValue': {'path': '/myMin'}, + 'maxValue': {'path': '/myMax'}, + }, + ), + ]; + manager.handleMessage( + SurfaceUpdate(surfaceId: surfaceId, components: components), + ); + manager.handleMessage(const CreateSurface(surfaceId: surfaceId)); + manager.handleMessage( + const DataModelUpdate( + surfaceId: surfaceId, + contents: {'myValue': 5.0, 'myMin': 0.0, 'myMax': 10.0}, + ), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: GenUiSurface(host: manager, surfaceId: surfaceId), + ), + ), + ); + + final Slider slider = tester.widget(find.byType(Slider)); + expect(slider.value, 5.0); + expect(slider.min, 0.0); + expect(slider.max, 10.0); + + // Update min/max via data model + manager.handleMessage( + const DataModelUpdate( + surfaceId: surfaceId, + contents: {'myMin': 2.0, 'myMax': 8.0}, + ), + ); + await tester.pumpAndSettle(); + + final Slider sliderUpdated = tester.widget(find.byType(Slider)); + expect(sliderUpdated.min, 2.0); + expect(sliderUpdated.max, 8.0); + }); } diff --git a/packages/genui/test/catalog/core_widgets/tabs_test.dart b/packages/genui/test/catalog/core_widgets/tabs_test.dart index 7d106c33f..2dc0a81b7 100644 --- a/packages/genui/test/catalog/core_widgets/tabs_test.dart +++ b/packages/genui/test/catalog/core_widgets/tabs_test.dart @@ -17,7 +17,7 @@ void main() { const surfaceId = 'testSurface'; final components = [ const Component( - id: 'tabs', + id: 'root', props: { 'component': 'Tabs', 'tabItems': [ @@ -35,26 +35,22 @@ void main() { const Component( id: 'text1', props: { - 'Text': { - 'text': {'literalString': 'This is the first tab.'}, - }, + 'component': 'Text', + 'text': {'literalString': 'This is the first tab.'}, }, ), const Component( id: 'text2', props: { - 'Text': { - 'text': {'literalString': 'This is the second tab.'}, - }, + 'component': 'Text', + 'text': {'literalString': 'This is the second tab.'}, }, ), ]; manager.handleMessage( SurfaceUpdate(surfaceId: surfaceId, components: components), ); - manager.handleMessage( - const CreateSurface(surfaceId: surfaceId), - ); + manager.handleMessage(const CreateSurface(surfaceId: surfaceId)); await tester.pumpWidget( MaterialApp( diff --git a/packages/genui/test/catalog/core_widgets/text_test.dart b/packages/genui/test/catalog/core_widgets/text_test.dart index bb782e6f5..e855d85f5 100644 --- a/packages/genui/test/catalog/core_widgets/text_test.dart +++ b/packages/genui/test/catalog/core_widgets/text_test.dart @@ -24,7 +24,7 @@ void main() { 'component': 'Text', 'text': {'literalString': 'Hello World'}, }, - id: 'test_text', + id: 'root', buildChild: (_, [_]) => const SizedBox(), dispatchEvent: (UiEvent event) {}, buildContext: context, @@ -55,7 +55,7 @@ void main() { 'text': {'literalString': 'Heading 1'}, 'usageHint': 'h1', }, - id: 'test_text_h1', + id: 'root', buildChild: (_, [_]) => const SizedBox(), dispatchEvent: (UiEvent event) {}, buildContext: context, @@ -108,7 +108,7 @@ void main() { data: { 'text': {'literalString': 'Hello **Bold**'}, }, - id: 'test_text_markdown', + id: 'root', buildChild: (_, [_]) => const SizedBox(), dispatchEvent: (UiEvent event) {}, buildContext: context, diff --git a/packages/genui/test/catalog/core_widgets_test.dart b/packages/genui/test/catalog/core_widgets_test.dart index 00e0c8737..9b2e187d3 100644 --- a/packages/genui/test/catalog/core_widgets_test.dart +++ b/packages/genui/test/catalog/core_widgets_test.dart @@ -29,9 +29,7 @@ void main() { manager!.handleMessage( SurfaceUpdate(surfaceId: surfaceId, components: components), ); - manager!.handleMessage( - const CreateSurface(surfaceId: surfaceId), - ); + manager!.handleMessage(const CreateSurface(surfaceId: surfaceId)); await tester.pumpWidget( MaterialApp( home: Scaffold( diff --git a/packages/genui/test/catalog_test.dart b/packages/genui/test/catalog_test.dart index 2d6cfe3b4..2fa69c6af 100644 --- a/packages/genui/test/catalog_test.dart +++ b/packages/genui/test/catalog_test.dart @@ -55,9 +55,7 @@ void main() { final catalog = const Catalog([]); final Map data = { 'id': 'text1', - 'widget': { - 'component': 'unknown_widget', 'text': 'hello', - }, + 'widget': {'component': 'unknown_widget', 'text': 'hello'}, }; final Future logFuture = expectLater( diff --git a/packages/genui/test/core/genui_manager_test.dart b/packages/genui/test/core/genui_manager_test.dart index fd50727da..06bc8f5ca 100644 --- a/packages/genui/test/core/genui_manager_test.dart +++ b/packages/genui/test/core/genui_manager_test.dart @@ -50,9 +50,7 @@ void main() { expect(addedUpdate.surfaceId, surfaceId); final Future futureUpdated = manager.surfaceUpdates.first; - manager.handleMessage( - const CreateSurface(surfaceId: surfaceId), - ); + manager.handleMessage(const CreateSurface(surfaceId: surfaceId)); final GenUiUpdate updatedUpdate = await futureUpdated; expect(updatedUpdate, isA()); diff --git a/packages/genui/test/core/ui_tools_test.dart b/packages/genui/test/core/ui_tools_test.dart index f28d42563..8d40e8609 100644 --- a/packages/genui/test/core/ui_tools_test.dart +++ b/packages/genui/test/core/ui_tools_test.dart @@ -92,9 +92,7 @@ void main() { final tool = CreateSurfaceTool(handleMessage: fakeHandleMessage); - final Map args = { - surfaceIdKey: 'testSurface', - }; + final Map args = {surfaceIdKey: 'testSurface'}; await tool.invoke(args); diff --git a/packages/genui/test/genui_surface_test.dart b/packages/genui/test/genui_surface_test.dart index a9180bcad..161c6e164 100644 --- a/packages/genui/test/genui_surface_test.dart +++ b/packages/genui/test/genui_surface_test.dart @@ -37,9 +37,7 @@ void main() { manager.handleMessage( SurfaceUpdate(surfaceId: surfaceId, components: components), ); - manager.handleMessage( - const CreateSurface(surfaceId: surfaceId), - ); + manager.handleMessage(const CreateSurface(surfaceId: surfaceId)); await tester.pumpWidget( MaterialApp( @@ -77,9 +75,7 @@ void main() { manager.handleMessage( SurfaceUpdate(surfaceId: surfaceId, components: components), ); - manager.handleMessage( - const CreateSurface(surfaceId: surfaceId), - ); + manager.handleMessage(const CreateSurface(surfaceId: surfaceId)); await tester.pumpWidget( MaterialApp( diff --git a/packages/genui/test/ui_tools_test.dart b/packages/genui/test/ui_tools_test.dart index 03f4b975d..0dd73b263 100644 --- a/packages/genui/test/ui_tools_test.dart +++ b/packages/genui/test/ui_tools_test.dart @@ -69,13 +69,9 @@ void main() { }); test('CreateSurfaceTool sends CreateSurface message', () async { - final tool = CreateSurfaceTool( - handleMessage: genUiManager.handleMessage, - ); + final tool = CreateSurfaceTool(handleMessage: genUiManager.handleMessage); - final Map args = { - surfaceIdKey: 'testSurface', - }; + final Map args = {surfaceIdKey: 'testSurface'}; // First, add a component to the surface so that the root can be set. genUiManager.handleMessage( @@ -98,8 +94,7 @@ void main() { final Future future = expectLater( genUiManager.surfaceUpdates, emits( - isA() - .having( + isA().having( (e) => e.surfaceId, surfaceIdKey, 'testSurface', diff --git a/packages/genui_a2ui/test/a2ui_content_generator_test.dart b/packages/genui_a2ui/test/a2ui_content_generator_test.dart index dbf7103a5..a3e94dd9d 100644 --- a/packages/genui_a2ui/test/a2ui_content_generator_test.dart +++ b/packages/genui_a2ui/test/a2ui_content_generator_test.dart @@ -82,8 +82,7 @@ void main() { 'components': [ { 'id': 'c1', - 'props': {'component': 'Column', 'children': [], - }, + 'props': {'component': 'Column', 'children': []}, }, ], }, diff --git a/packages/genui_firebase_ai/lib/src/firebase_ai_content_generator.dart b/packages/genui_firebase_ai/lib/src/firebase_ai_content_generator.dart index 5722fbfd0..0ce836343 100644 --- a/packages/genui_firebase_ai/lib/src/firebase_ai_content_generator.dart +++ b/packages/genui_firebase_ai/lib/src/firebase_ai_content_generator.dart @@ -428,7 +428,3 @@ With functions: _textResponseController.add(line); } } - - - - diff --git a/packages/genui_firebase_ai/test/firebase_ai_content_generator_test.dart b/packages/genui_firebase_ai/test/firebase_ai_content_generator_test.dart index f1f31b635..39c758e51 100644 --- a/packages/genui_firebase_ai/test/firebase_ai_content_generator_test.dart +++ b/packages/genui_firebase_ai/test/firebase_ai_content_generator_test.dart @@ -21,9 +21,7 @@ void main() { return FakeGeminiGenerativeModel([ GenerateContentResponse([ Candidate( - Content.model([ - const TextPart('{"response": "Hello"}'), - ]), + Content.model([const TextPart('{"response": "Hello"}')]), [], null, FinishReason.stop, @@ -68,9 +66,7 @@ void main() { ], null), GenerateContentResponse([ Candidate( - Content.model([ - const TextPart('Tool called'), - ]), + Content.model([const TextPart('Tool called')]), [], null, FinishReason.stop, @@ -97,9 +93,7 @@ void main() { return FakeGeminiGenerativeModel([ GenerateContentResponse([ Candidate( - Content.model([ - const TextPart('Hello'), - ]), + Content.model([const TextPart('Hello')]), [], null, FinishReason.stop, diff --git a/packages/genui_google_generative_ai/lib/src/google_generative_ai_content_generator.dart b/packages/genui_google_generative_ai/lib/src/google_generative_ai_content_generator.dart index c3f090e12..8212ff156 100644 --- a/packages/genui_google_generative_ai/lib/src/google_generative_ai_content_generator.dart +++ b/packages/genui_google_generative_ai/lib/src/google_generative_ai_content_generator.dart @@ -106,9 +106,7 @@ class GoogleGenerativeAiContentGenerator implements ContentGenerator { _isProcessing.value = true; try { final messages = [...?history, message]; - await _generate( - messages: messages, - ); + await _generate(messages: messages); } catch (e, st) { genUiLogger.severe('Error generating content', e, st); _errorController.add(ContentGeneratorError(e, st)); @@ -269,9 +267,7 @@ class GoogleGenerativeAiContentGenerator implements ContentGenerator { return (functionResponseParts: functionResponseParts); } - Future _generate({ - required Iterable messages, - }) async { + Future _generate({required Iterable messages}) async { final converter = GoogleContentConverter(); final adapter = GoogleSchemaAdapter(); @@ -464,5 +460,3 @@ With functions: _textResponseController.add(line); } } - - diff --git a/packages/genui_google_generative_ai/test/google_generative_ai_content_generator_test.dart b/packages/genui_google_generative_ai/test/google_generative_ai_content_generator_test.dart index f1466a8f2..bf57f8266 100644 --- a/packages/genui_google_generative_ai/test/google_generative_ai_content_generator_test.dart +++ b/packages/genui_google_generative_ai/test/google_generative_ai_content_generator_test.dart @@ -117,9 +117,7 @@ void main() { google_ai.Candidate( content: google_ai.Content( role: 'model', - parts: [ - google_ai.Part(text: '{"response": "Hello"}'), - ], + parts: [google_ai.Part(text: '{"response": "Hello"}')], ), finishReason: google_ai.Candidate_FinishReason.stop, ), @@ -138,65 +136,60 @@ void main() { expect(generator.isProcessing.value, isFalse); }); - test( - 'can call a tool and return a result', - () async { - final generator = GoogleGenerativeAiContentGenerator( - catalog: const genui.Catalog({}), - additionalTools: [ - genui.DynamicAiTool>( - name: 'testTool', - description: 'A test tool', - parameters: dsb.Schema.object(), - invokeFunction: (args) async => {'result': 'tool result'}, - ), - ], - serviceFactory: ({required configuration}) { - return FakeGoogleGenerativeService([ - google_ai.GenerateContentResponse( - candidates: [ - google_ai.Candidate( - content: google_ai.Content( - role: 'model', - parts: [ - google_ai.Part( - functionCall: google_ai.FunctionCall( - id: '1', - name: 'testTool', + test('can call a tool and return a result', () async { + final generator = GoogleGenerativeAiContentGenerator( + catalog: const genui.Catalog({}), + additionalTools: [ + genui.DynamicAiTool>( + name: 'testTool', + description: 'A test tool', + parameters: dsb.Schema.object(), + invokeFunction: (args) async => {'result': 'tool result'}, + ), + ], + serviceFactory: ({required configuration}) { + return FakeGoogleGenerativeService([ + google_ai.GenerateContentResponse( + candidates: [ + google_ai.Candidate( + content: google_ai.Content( + role: 'model', + parts: [ + google_ai.Part( + functionCall: google_ai.FunctionCall( + id: '1', + name: 'testTool', args: protobuf.Struct.fromJson({}), - ), ), - ], - ), - finishReason: google_ai.Candidate_FinishReason.stop, + ), + ], ), - ], - ), - google_ai.GenerateContentResponse( - candidates: [ - google_ai.Candidate( - content: google_ai.Content( - role: 'model', - parts: [ - google_ai.Part(text: 'Tool called'), - ], - ), - finishReason: google_ai.Candidate_FinishReason.stop, + finishReason: google_ai.Candidate_FinishReason.stop, + ), + ], + ), + google_ai.GenerateContentResponse( + candidates: [ + google_ai.Candidate( + content: google_ai.Content( + role: 'model', + parts: [google_ai.Part(text: 'Tool called')], ), - ], - ), - ]); - }, - ); - - final hi = genui.UserMessage([const genui.TextPart('Hi')]); - final completer = Completer(); - unawaited(generator.textResponseStream.first.then(completer.complete)); - await generator.sendRequest(hi); - final response = await completer.future; - expect(response, 'Tool called'); - }, - ); + finishReason: google_ai.Candidate_FinishReason.stop, + ), + ], + ), + ]); + }, + ); + + final hi = genui.UserMessage([const genui.TextPart('Hi')]); + final completer = Completer(); + unawaited(generator.textResponseStream.first.then(completer.complete)); + await generator.sendRequest(hi); + final response = await completer.future; + expect(response, 'Tool called'); + }); test('returns a simple text response', () async { final generator = GoogleGenerativeAiContentGenerator( @@ -208,9 +201,7 @@ void main() { google_ai.Candidate( content: google_ai.Content( role: 'model', - parts: [ - google_ai.Part(text: 'Hello'), - ], + parts: [google_ai.Part(text: 'Hello')], ), finishReason: google_ai.Candidate_FinishReason.stop, ), From 4e7da395ce1795c21df022bc9580c5a4b6bf613f Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Fri, 21 Nov 2025 16:07:36 -0800 Subject: [PATCH 08/11] Fix multiple choice subscriptions --- .../catalog/core_widgets/multiple_choice.dart | 228 ++++++++++-------- .../genui/lib/src/model/a2ui_schemas.dart | 14 ++ .../core_widgets/multiple_choice_test.dart | 70 +++++- 3 files changed, 196 insertions(+), 116 deletions(-) diff --git a/packages/genui/lib/src/catalog/core_widgets/multiple_choice.dart b/packages/genui/lib/src/catalog/core_widgets/multiple_choice.dart index e565faf94..6908dc1d7 100644 --- a/packages/genui/lib/src/catalog/core_widgets/multiple_choice.dart +++ b/packages/genui/lib/src/catalog/core_widgets/multiple_choice.dart @@ -14,15 +14,7 @@ import '../../primitives/simple_items.dart'; final _schema = S.object( properties: { 'selections': A2uiSchemas.stringArrayReference(), - 'options': S.list( - items: S.object( - properties: { - 'label': A2uiSchemas.stringReference(), - 'value': S.string(), - }, - required: ['label', 'value'], - ), - ), + 'options': A2uiSchemas.objectArrayReference(), 'maxAllowedSelections': S.integer(), }, required: ['selections', 'options'], @@ -31,7 +23,7 @@ final _schema = S.object( extension type _MultipleChoiceData.fromMap(JsonMap _json) { factory _MultipleChoiceData({ required JsonMap selections, - required List options, + required JsonMap options, int? maxAllowedSelections, }) => _MultipleChoiceData.fromMap({ 'selections': selections, @@ -40,7 +32,7 @@ extension type _MultipleChoiceData.fromMap(JsonMap _json) { }); JsonMap get selections => _json['selections'] as JsonMap; - List get options => (_json['options'] as List).cast(); + JsonMap get options => _json['options'] as JsonMap; int? get maxAllowedSelections => _json['maxAllowedSelections'] as int?; } @@ -67,78 +59,98 @@ final multipleChoice = CatalogItem( final ValueNotifier?> selectionsNotifier = itemContext .dataContext .subscribeToObjectArray(multipleChoiceData.selections); + final ValueNotifier?> optionsNotifier = itemContext + .dataContext + .subscribeToObjectArray(multipleChoiceData.options); return ValueListenableBuilder?>( valueListenable: selectionsNotifier, builder: (context, selections, child) { - return Column( - children: multipleChoiceData.options.map((option) { - final ValueNotifier labelNotifier = itemContext.dataContext - .subscribeToString(option['label'] as JsonMap); - final value = option['value'] as String; - return ValueListenableBuilder( - valueListenable: labelNotifier, - builder: (context, label, child) { - if (multipleChoiceData.maxAllowedSelections == 1) { - final Object? groupValue = selections?.isNotEmpty == true - ? selections!.first - : null; - return RadioListTile( - controlAffinity: ListTileControlAffinity.leading, - dense: true, - title: Text( - label ?? '', - style: Theme.of(context).textTheme.bodyMedium, - ), - value: value, - // ignore: deprecated_member_use - groupValue: groupValue is String ? groupValue : null, - // ignore: deprecated_member_use - onChanged: (newValue) { - final path = - multipleChoiceData.selections['path'] as String?; - if (path == null || newValue == null) { - return; - } - itemContext.dataContext.update(DataPath(path), [ - newValue, - ]); - }, - ); + return ValueListenableBuilder?>( + valueListenable: optionsNotifier, + builder: (context, options, child) { + if (options == null) { + return const SizedBox.shrink(); + } + return Column( + children: options.map((optionObj) { + final option = optionObj as JsonMap; + final Object? labelObj = option['label']; + final ValueNotifier labelNotifier; + if (labelObj is String) { + labelNotifier = ValueNotifier(labelObj); } else { - return CheckboxListTile( - title: Text(label ?? ''), - dense: true, - controlAffinity: ListTileControlAffinity.leading, - value: selections?.contains(value) ?? false, - onChanged: (newValue) { - final path = - multipleChoiceData.selections['path'] as String?; - if (path == null) { - return; - } - final List newSelections = - selections?.map((e) => e.toString()).toList() ?? - []; - if (newValue ?? false) { - if (multipleChoiceData.maxAllowedSelections == null || - newSelections.length < - multipleChoiceData.maxAllowedSelections!) { - newSelections.add(value); - } - } else { - newSelections.remove(value); - } - itemContext.dataContext.update( - DataPath(path), - newSelections, - ); - }, + labelNotifier = itemContext.dataContext.subscribeToString( + labelObj as JsonMap?, ); } - }, + final value = option['value'] as String; + return ValueListenableBuilder( + valueListenable: labelNotifier, + builder: (context, label, child) { + if (multipleChoiceData.maxAllowedSelections == 1) { + final Object? groupValue = selections?.isNotEmpty == true + ? selections!.first + : null; + return RadioListTile( + controlAffinity: ListTileControlAffinity.leading, + dense: true, + title: Text( + label ?? '', + style: Theme.of(context).textTheme.bodyMedium, + ), + value: value, + // ignore: deprecated_member_use + groupValue: groupValue is String ? groupValue : null, + // ignore: deprecated_member_use + onChanged: (newValue) { + final path = + multipleChoiceData.selections['path'] as String?; + if (path == null || newValue == null) { + return; + } + itemContext.dataContext.update(DataPath(path), [ + newValue, + ]); + }, + ); + } else { + return CheckboxListTile( + title: Text(label ?? ''), + dense: true, + controlAffinity: ListTileControlAffinity.leading, + value: selections?.contains(value) ?? false, + onChanged: (newValue) { + final path = + multipleChoiceData.selections['path'] as String?; + if (path == null) { + return; + } + final List newSelections = + selections?.map((e) => e.toString()).toList() ?? + []; + if (newValue ?? false) { + if (multipleChoiceData.maxAllowedSelections == + null || + newSelections.length < + multipleChoiceData.maxAllowedSelections!) { + newSelections.add(value); + } + } else { + newSelections.remove(value); + } + itemContext.dataContext.update( + DataPath(path), + newSelections, + ); + }, + ); + } + }, + ); + }).toList(), ); - }).toList(), + }, ); }, ); @@ -177,20 +189,22 @@ final multipleChoice = CatalogItem( "path": "/singleSelection" }, "maxAllowedSelections": 1, - "options": [ - { - "label": { - "literalString": "Option A" - }, - "value": "A" - }, - { - "label": { - "literalString": "Option B" + "options": { + "literalArray": [ + { + "label": { + "literalString": "Option A" + }, + "value": "A" }, - "value": "B" - } - ] + { + "label": { + "literalString": "Option B" + }, + "value": "B" + } + ] + } } }, { @@ -209,26 +223,28 @@ final multipleChoice = CatalogItem( "selections": { "path": "/multiSelection" }, - "options": [ - { - "label": { - "literalString": "Option X" - }, - "value": "X" - }, - { - "label": { - "literalString": "Option Y" + "options": { + "literalArray": [ + { + "label": { + "literalString": "Option X" + }, + "value": "X" }, - "value": "Y" - }, - { - "label": { - "literalString": "Option Z" + { + "label": { + "literalString": "Option Y" + }, + "value": "Y" }, - "value": "Z" - } - ] + { + "label": { + "literalString": "Option Z" + }, + "value": "Z" + } + ] + } } } ] diff --git a/packages/genui/lib/src/model/a2ui_schemas.dart b/packages/genui/lib/src/model/a2ui_schemas.dart index c95c65b54..3e9b5f295 100644 --- a/packages/genui/lib/src/model/a2ui_schemas.dart +++ b/packages/genui/lib/src/model/a2ui_schemas.dart @@ -131,6 +131,20 @@ class A2uiSchemas { }, ); + /// Schema for a value that can be either a literal array of objects (maps) + /// or a data-bound path to an array of objects in the DataModel. If both + /// path and literalArray are provided, the value at the path will be + /// initialized with the literalArray. + static Schema objectArrayReference({String? description}) => S.object( + description: description, + properties: { + 'path': S.string( + description: 'A relative or absolute path in the data model.', + ), + 'literalArray': S.list(items: S.object(additionalProperties: true)), + }, + ); + /// Schema for a createSurface message, which initializes a surface. static Schema createSurfaceSchema() => S.object( properties: { 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 20902c3b8..b0bddbce9 100644 --- a/packages/genui/test/catalog/core_widgets/multiple_choice_test.dart +++ b/packages/genui/test/catalog/core_widgets/multiple_choice_test.dart @@ -21,16 +21,18 @@ void main() { props: { 'component': 'MultipleChoice', 'selections': {'path': '/mySelections'}, - 'options': [ - { - 'label': {'literalString': 'Option 1'}, - 'value': '1', - }, - { - 'label': {'literalString': 'Option 2'}, - 'value': '2', - }, - ], + 'options': { + 'literalArray': [ + { + 'label': {'literalString': 'Option 1'}, + 'value': '1', + }, + { + 'label': {'literalString': 'Option 2'}, + 'value': '2', + }, + ], + }, }, ), ]; @@ -69,4 +71,52 @@ void main() { ['1', '2'], ); }); + + testWidgets( + 'MultipleChoice widget handles simple string labels from data model', + (WidgetTester tester) async { + final manager = GenUiManager( + catalog: Catalog([CoreCatalogItems.multipleChoice]), + configuration: const GenUiConfiguration(), + ); + const surfaceId = 'testSurfaceSimple'; + final components = [ + const Component( + id: 'root', + props: { + 'component': 'MultipleChoice', + 'selections': {'path': '/mySelections'}, + 'options': {'path': '/myOptions'}, + }, + ), + ]; + manager.handleMessage( + SurfaceUpdate(surfaceId: surfaceId, components: components), + ); + manager.handleMessage(const CreateSurface(surfaceId: surfaceId)); + manager.handleMessage( + const DataModelUpdate( + surfaceId: surfaceId, + contents: { + 'mySelections': [], + 'myOptions': [ + {'label': 'Simple Option 1', 'value': 's1'}, + {'label': 'Simple Option 2', 'value': 's2'}, + ], + }, + ), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: GenUiSurface(host: manager, surfaceId: surfaceId), + ), + ), + ); + + expect(find.text('Simple Option 1'), findsOneWidget); + expect(find.text('Simple Option 2'), findsOneWidget); + }, + ); } From 5481bf01e3dc30d7d8b2d838d3d066c4e827771a Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Fri, 21 Nov 2025 16:49:29 -0800 Subject: [PATCH 09/11] Fix prompt to include a root widget --- .../lib/src/travel_planner_page.dart | 110 +---------- .../genui/lib/src/model/a2ui_schemas.dart | 6 +- .../catalog/core_widgets/text_field_test.dart | 68 +++++++ .../genui/test/catalog/core_widgets_test.dart | 171 ------------------ 4 files changed, 80 insertions(+), 275 deletions(-) create mode 100644 packages/genui/test/catalog/core_widgets/text_field_test.dart delete mode 100644 packages/genui/test/catalog/core_widgets_test.dart diff --git a/examples/travel_app/lib/src/travel_planner_page.dart b/examples/travel_app/lib/src/travel_planner_page.dart index 2995c4a14..e5357b3d4 100644 --- a/examples/travel_app/lib/src/travel_planner_page.dart +++ b/examples/travel_app/lib/src/travel_planner_page.dart @@ -433,110 +433,14 @@ ${_imagesJson ?? ''} ## Example -Here is an example of the arguments to the `surfaceUpdate` tool. Note that the -`root` widget ID must be present in the `widgets` list, and it should contain -the other widgets. - -```json -{ - "surfaceId": "mexico_trip_planner", - "definition": { - "root": "root_column", - "widgets": [ - { - "id": "root_column", - "widget": { - "Column": { - "children": ["trip_title", "itinerary"] - } - } - }, - { - "id": "trip_title", - "widget": { - "Text": { - "text": "Trip to Mexico City" - } - } - }, - { - "id": "itinerary", - "widget": { - "ItineraryWithDetails": { - "title": "Mexico City Adventure", - "subheading": "3-day Itinerary", - "imageChildId": "mexico_city_image", - "child": "itinerary_details" - } - } - }, - { - "id": "mexico_city_image", - "widget": { - "Image": { - "location": "assets/travel_images/mexico_city.jpg" - } - } - }, - { - "id": "itinerary_details", - "widget": { - "Column": { - "children": ["day1"] - } - } - }, - { - "id": "day1", - "widget": { - "ItineraryDay": { - "title": "Day 1", - "subtitle": "Arrival and Exploration", - "description": "Your first day in Mexico City will be focused on settling in and exploring the historic center.", - "imageChildId": "day1_image", - "children": ["day1_entry1", "day1_entry2"] - } - } - }, - { - "id": "day1_image", - "widget": { - "Image": { - "location": "assets/travel_images/mexico_city.jpg" - } - } - }, - { - "id": "day1_entry1", - "widget": { - "ItineraryEntry": { - "type": "transport", - "title": "Arrival at MEX Airport", - "time": "2:00 PM", - "bodyText": "Arrive at Mexico City International Airport (MEX), clear customs, and pick up your luggage.", - "status": "noBookingRequired" - } - } - }, - { - "id": "day1_entry2", - "widget": { - "ItineraryEntry": { - "type": "activity", - "title": "Explore the Zocalo", - "subtitle": "Historic Center", - "time": "4:00 PM - 6:00 PM", - "address": "Plaza de la Constitución S/N, Centro Histórico, Ciudad de México", - "bodyText": "Head to the Zocalo, the main square of Mexico City. Visit the Metropolitan Cathedral and the National Palace.", - "status": "noBookingRequired" - } - } - } - ] - } -} +Here is an example of a `surfaceUpdate` message. Note that the `root` component +ID must be present in the `components` list, and it should contain the other +components. + +```jsonl +{"surfaceUpdate":{"surfaceId":"mexico_trip_planner","components":[{"id":"root","props":{"component":"Column","children":{"explicitList":["trip_title","itinerary"]}}},{"id":"trip_title","props":{"component":"Text","text":{"literalString":"Trip to Mexico City"}}},{"id":"itinerary","props":{"component":"Itinerary","title":{"literalString":"Mexico City Adventure"},"subheading":{"literalString":"3-day Itinerary"},"imageChildId":"mexico_city_image","days":[{"title":{"literalString":"Day 1"},"subtitle":{"literalString":"Arrival and Exploration"},"description":{"literalString":"Your first day in Mexico City will be focused on settling in and exploring the historic center."},"imageChildId":"day1_image","entries":[{"type":"transport","title":{"literalString":"Arrival at MEX Airport"},"time":{"literalString":"2:00 PM"},"bodyText":{"literalString":"Arrive at Mexico City International Airport (MEX), clear customs, and pick up your luggage."},"status":"noBookingRequired"},{"type":"activity","title":{"literalString":"Explore the Zocalo"},"subtitle":{"literalString":"Historic Center"},"time":{"literalString":"4:00 PM - 6:00 PM"},"address":{"literalString":"Plaza de la Constitución S/N, Centro Histórico, Ciudad de México"},"bodyText":{"literalString":"Head to the Zocalo, the main square of Mexico City. Visit the Metropolitan Cathedral and the National Palace."},"status":"noBookingRequired"}]}]}},{"id":"mexico_city_image","props":{"component":"Image","url":{"literalString":"assets/travel_images/mexico_city.jpg"}}},{"id":"day1_image","props":{"component":"Image","url":{"literalString":"assets/travel_images/mexico_city.jpg"}}}]}} ``` -When updating or showing UIs, **ALWAYS** use the surfaceUpdate tool to supply +When updating or showing UIs, **ALWAYS** send a `surfaceUpdate` message to supply them. Prefer to collect and show information by creating a UI for it. '''; diff --git a/packages/genui/lib/src/model/a2ui_schemas.dart b/packages/genui/lib/src/model/a2ui_schemas.dart index 3e9b5f295..c1b84cc6a 100644 --- a/packages/genui/lib/src/model/a2ui_schemas.dart +++ b/packages/genui/lib/src/model/a2ui_schemas.dart @@ -201,7 +201,11 @@ class A2uiSchemas { 'Represents a *single* component in a UI widget tree. ' 'This component could be one of many supported types.', properties: { - 'id': S.string(), + 'id': S.string( + description: + 'The unique identifier for this component. The root component ' + "of the surface MUST have the id 'root'.", + ), 'weight': S.integer( description: 'Optional layout weight for use in Row/Column children.', diff --git a/packages/genui/test/catalog/core_widgets/text_field_test.dart b/packages/genui/test/catalog/core_widgets/text_field_test.dart new file mode 100644 index 000000000..275fe308e --- /dev/null +++ b/packages/genui/test/catalog/core_widgets/text_field_test.dart @@ -0,0 +1,68 @@ +// 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/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:genui/genui.dart'; + +void main() { + testWidgets('TextField renders and handles changes/submissions', ( + WidgetTester tester, + ) async { + ChatMessage? message; + final manager = GenUiManager( + catalog: Catalog([CoreCatalogItems.textField]), + configuration: const GenUiConfiguration(), + ); + manager.onSubmit.listen((event) => message = event); + const surfaceId = 'testSurface'; + final components = [ + const Component( + id: 'root', + props: { + 'component': 'TextField', + 'text': {'path': '/myValue'}, + 'label': {'literalString': 'My Label'}, + 'onSubmittedAction': {'name': 'submit'}, + }, + ), + ]; + manager.handleMessage( + SurfaceUpdate(surfaceId: surfaceId, components: components), + ); + manager.handleMessage(const CreateSurface(surfaceId: surfaceId)); + manager.dataModelForSurface(surfaceId).update( + DataPath('/myValue'), + 'initial', + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: GenUiSurface(host: manager, surfaceId: surfaceId), + ), + ), + ); + await tester.pumpAndSettle(); + + final Finder textFieldFinder = find.byType(TextField); + expect(find.widgetWithText(TextField, 'initial'), findsOneWidget); + final TextField textField = tester.widget(textFieldFinder); + expect(textField.decoration?.labelText, 'My Label'); + + // Test onChanged + await tester.enterText(textFieldFinder, 'new value'); + expect( + manager + .dataModelForSurface(surfaceId) + .getValue(DataPath('/myValue')), + 'new value', + ); + + // Test onSubmitted + expect(message, null); + await tester.testTextInput.receiveAction(TextInputAction.done); + expect(message, isNotNull); + }); +} diff --git a/packages/genui/test/catalog/core_widgets_test.dart b/packages/genui/test/catalog/core_widgets_test.dart deleted file mode 100644 index 9b2e187d3..000000000 --- a/packages/genui/test/catalog/core_widgets_test.dart +++ /dev/null @@ -1,171 +0,0 @@ -// 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/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:genui/genui.dart'; - -void main() { - group('Core Widgets', () { - final Catalog testCatalog = CoreCatalogItems.asCatalog(); - - ChatMessage? message; - GenUiManager? manager; - - Future pumpWidgetWithDefinition( - WidgetTester tester, - String rootId, - List components, - ) async { - message = null; - manager?.dispose(); - manager = GenUiManager( - catalog: testCatalog, - configuration: const GenUiConfiguration(), - ); - manager!.onSubmit.listen((event) => message = event); - const surfaceId = 'testSurface'; - manager!.handleMessage( - SurfaceUpdate(surfaceId: surfaceId, components: components), - ); - manager!.handleMessage(const CreateSurface(surfaceId: surfaceId)); - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: GenUiSurface(host: manager!, surfaceId: surfaceId), - ), - ), - ); - } - - testWidgets('Button renders and handles taps', (WidgetTester tester) async { - final components = [ - const Component( - id: 'button', - props: { - 'Button': { - 'child': 'text', - 'action': {'name': 'testAction'}, - }, - }, - ), - const Component( - id: 'text', - props: { - 'Text': { - 'text': {'literalString': 'Click Me'}, - }, - }, - ), - ]; - - await pumpWidgetWithDefinition(tester, 'button', components); - - expect(find.text('Click Me'), findsOneWidget); - - expect(message, null); - await tester.tap(find.byType(ElevatedButton)); - expect(message, isNotNull); - }); - - testWidgets('Text renders from data model', (WidgetTester tester) async { - final components = [ - const Component( - id: 'text', - props: { - 'Text': { - 'text': {'path': '/myText'}, - }, - }, - ), - ]; - - await pumpWidgetWithDefinition(tester, 'text', components); - manager! - .dataModelForSurface('testSurface') - .update(DataPath('/myText'), 'Hello from data model'); - await tester.pumpAndSettle(); - - expect(find.text('Hello from data model'), findsOneWidget); - }); - - testWidgets('Column renders children', (WidgetTester tester) async { - final components = [ - const Component( - id: 'col', - props: { - 'Column': { - 'children': { - 'explicitList': ['text1', 'text2'], - }, - }, - }, - ), - const Component( - id: 'text1', - props: { - 'Text': { - 'text': {'literalString': 'First'}, - }, - }, - ), - const Component( - id: 'text2', - props: { - 'Text': { - 'text': {'literalString': 'Second'}, - }, - }, - ), - ]; - - await pumpWidgetWithDefinition(tester, 'col', components); - - expect(find.text('First'), findsOneWidget); - expect(find.text('Second'), findsOneWidget); - }); - - testWidgets('TextField renders and handles changes/submissions', ( - WidgetTester tester, - ) async { - final components = [ - const Component( - id: 'field', - props: { - 'TextField': { - 'text': {'path': '/myValue'}, - 'label': {'literalString': 'My Label'}, - 'onSubmittedAction': {'name': 'submit'}, - }, - }, - ), - ]; - - await pumpWidgetWithDefinition(tester, 'field', components); - manager! - .dataModelForSurface('testSurface') - .update(DataPath('/myValue'), 'initial'); - await tester.pumpAndSettle(); - - final Finder textFieldFinder = find.byType(TextField); - expect(find.widgetWithText(TextField, 'initial'), findsOneWidget); - final TextField textField = tester.widget(textFieldFinder); - expect(textField.decoration?.labelText, 'My Label'); - - // Test onChanged - await tester.enterText(textFieldFinder, 'new value'); - expect( - manager! - .dataModelForSurface('testSurface') - .getValue(DataPath('/myValue')), - 'new value', - ); - - // Test onSubmitted - expect(message, null); - await tester.testTextInput.receiveAction(TextInputAction.done); - expect(message, isNotNull); - }); - }); -} From f14d47c3dc0c2f33c9813c8a6aefc8dcfe8264c9 Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Fri, 21 Nov 2025 17:07:17 -0800 Subject: [PATCH 10/11] Add more logging --- .../src/firebase_ai_content_generator.dart | 6 +-- ...oogle_generative_ai_content_generator.dart | 45 +++++++++---------- 2 files changed, 25 insertions(+), 26 deletions(-) diff --git a/packages/genui_firebase_ai/lib/src/firebase_ai_content_generator.dart b/packages/genui_firebase_ai/lib/src/firebase_ai_content_generator.dart index 0ce836343..b457818e4 100644 --- a/packages/genui_firebase_ai/lib/src/firebase_ai_content_generator.dart +++ b/packages/genui_firebase_ai/lib/src/firebase_ai_content_generator.dart @@ -307,9 +307,9 @@ class FirebaseAiContentGenerator implements ContentGenerator { 'Just output one JSON object per line.', ), tools: generativeAiTools, - toolConfig: ToolConfig( - functionCallingConfig: FunctionCallingConfig.auto(), - ), + toolConfig: generativeAiTools == null + ? null + : ToolConfig(functionCallingConfig: FunctionCallingConfig.auto()), ); toolLoop: diff --git a/packages/genui_google_generative_ai/lib/src/google_generative_ai_content_generator.dart b/packages/genui_google_generative_ai/lib/src/google_generative_ai_content_generator.dart index 8212ff156..f479d7198 100644 --- a/packages/genui_google_generative_ai/lib/src/google_generative_ai_content_generator.dart +++ b/packages/genui_google_generative_ai/lib/src/google_generative_ai_content_generator.dart @@ -274,20 +274,6 @@ class GoogleGenerativeAiContentGenerator implements ContentGenerator { final service = serviceFactory(configuration: this); try { - final availableTools = [ - if (configuration.actions.allowCreate || - configuration.actions.allowUpdate) ...[ - SurfaceUpdateTool( - handleMessage: _a2uiMessageController.add, - catalog: catalog, - configuration: configuration, - ), - CreateSurfaceTool(handleMessage: _a2uiMessageController.add), - ], - if (configuration.actions.allowDelete) - DeleteSurfaceTool(handleMessage: _a2uiMessageController.add), - ...additionalTools, - ]; // A local copy of the incoming messages which is updated with // tool results @@ -295,7 +281,7 @@ class GoogleGenerativeAiContentGenerator implements ContentGenerator { final content = converter.toGoogleAiContent(messages); final (:tools, :allowedFunctionNames) = _setupToolsAndFunctions( - availableTools: availableTools, + availableTools: additionalTools, adapter: adapter, ); @@ -343,11 +329,13 @@ With functions: model: modelName, contents: [...systemInstructionContent, ...content], tools: tools, - toolConfig: google_ai.ToolConfig( - functionCallingConfig: google_ai.FunctionCallingConfig( - mode: google_ai.FunctionCallingConfig_Mode.auto, - ), - ), + toolConfig: tools == null + ? null + : google_ai.ToolConfig( + functionCallingConfig: google_ai.FunctionCallingConfig( + mode: google_ai.FunctionCallingConfig_Mode.auto, + ), + ), ); final responseStream = service.streamGenerateContent(request); @@ -361,6 +349,11 @@ With functions: } final candidate = response.candidates!.first; + genUiLogger.fine( + 'Received candidate: content=${candidate.content}, ' + 'finishReason=${candidate.finishReason}, ' + 'safetyRatings=${candidate.safetyRatings}', + ); // Handle function calls final functionCalls = []; @@ -383,7 +376,7 @@ With functions: final result = await _processFunctionCalls( functionCalls: functionCalls, - availableTools: availableTools, + availableTools: additionalTools, ); final functionResponseParts = result.functionResponseParts; @@ -404,6 +397,7 @@ With functions: for (final part in candidate.content!.parts!) { final text = part.text; if (text != null && text.isNotEmpty) { + genUiLogger.fine('Received text part: $text'); for (var i = 0; i < text.length; i++) { final char = text[i]; if (char == '\n') { @@ -443,19 +437,24 @@ With functions: line = line.trim(); if (line.isEmpty) return; + genUiLogger.fine('Processing line: $line'); + try { final json = jsonDecode(line); if (json is Map) { try { final message = A2uiMessage.fromJson(json); + genUiLogger.fine('Parsed A2UI message: $message'); _a2uiMessageController.add(message); return; - } catch (_) { + } catch (e) { // Not an A2UI message, treat as text/other JSON + genUiLogger.fine('Failed to parse as A2UI message: $e'); } } - } catch (_) { + } catch (e) { // Not JSON, treat as text + genUiLogger.fine('Failed to parse as JSON: $e'); } _textResponseController.add(line); } From aa8dbfb19d97d55aa8161ea2f9103cf664bcdff6 Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Fri, 21 Nov 2025 17:48:49 -0800 Subject: [PATCH 11/11] Fix prompting and JSON parsing --- .../travel_app/lib/src/travel_planner_page.dart | 11 +++-------- packages/genui/lib/src/model/a2ui_schemas.dart | 4 ++-- .../test/catalog/core_widgets/text_field_test.dart | 7 +++---- .../src/google_generative_ai_content_generator.dart | 13 +++++++++++-- 4 files changed, 19 insertions(+), 16 deletions(-) diff --git a/examples/travel_app/lib/src/travel_planner_page.dart b/examples/travel_app/lib/src/travel_planner_page.dart index e5357b3d4..24a2b5cfe 100644 --- a/examples/travel_app/lib/src/travel_planner_page.dart +++ b/examples/travel_app/lib/src/travel_planner_page.dart @@ -353,11 +353,9 @@ the user can return to the main booking flow once they have done some research. ## Controlling the UI -Use the provided tools to build and manage the user interface in response to the -user's requests. To display or update a UI, you must first call the -`surfaceUpdate` tool to define all the necessary components. After defining the -components, you must call the `beginRendering` tool to specify the root -component that should be displayed. +To display or update a UI, you must output a `surfaceUpdate` message in JSONL format. +The `surfaceUpdate` message must define all necessary components and specify the root +component in the `components` list. The root component must have the ID "root". - Adding surfaces: Most of the time, you should only add new surfaces to the conversation. This is less confusing for the user, because they can easily @@ -368,9 +366,6 @@ component that should be displayed. user because it avoids confusing the conversation with many versions of the same itinerary etc. -Once you add or update a surface and are waiting for user input, the -conversation turn is complete, and you should call the provideFinalOutput tool. - If you are displaying more than one component, you should use a `Column` widget as the root and add the other components as children. diff --git a/packages/genui/lib/src/model/a2ui_schemas.dart b/packages/genui/lib/src/model/a2ui_schemas.dart index c1b84cc6a..4a84c6d0a 100644 --- a/packages/genui/lib/src/model/a2ui_schemas.dart +++ b/packages/genui/lib/src/model/a2ui_schemas.dart @@ -203,8 +203,8 @@ class A2uiSchemas { properties: { 'id': S.string( description: - 'The unique identifier for this component. The root component ' - "of the surface MUST have the id 'root'.", + 'The unique identifier for this component. The root ' + "component of the surface MUST have the id 'root'.", ), 'weight': S.integer( description: diff --git a/packages/genui/test/catalog/core_widgets/text_field_test.dart b/packages/genui/test/catalog/core_widgets/text_field_test.dart index 275fe308e..a38ef6a5c 100644 --- a/packages/genui/test/catalog/core_widgets/text_field_test.dart +++ b/packages/genui/test/catalog/core_widgets/text_field_test.dart @@ -32,10 +32,9 @@ void main() { SurfaceUpdate(surfaceId: surfaceId, components: components), ); manager.handleMessage(const CreateSurface(surfaceId: surfaceId)); - manager.dataModelForSurface(surfaceId).update( - DataPath('/myValue'), - 'initial', - ); + manager + .dataModelForSurface(surfaceId) + .update(DataPath('/myValue'), 'initial'); await tester.pumpWidget( MaterialApp( diff --git a/packages/genui_google_generative_ai/lib/src/google_generative_ai_content_generator.dart b/packages/genui_google_generative_ai/lib/src/google_generative_ai_content_generator.dart index f479d7198..0e4dd8976 100644 --- a/packages/genui_google_generative_ai/lib/src/google_generative_ai_content_generator.dart +++ b/packages/genui_google_generative_ai/lib/src/google_generative_ai_content_generator.dart @@ -274,7 +274,6 @@ class GoogleGenerativeAiContentGenerator implements ContentGenerator { final service = serviceFactory(configuration: this); try { - // A local copy of the incoming messages which is updated with // tool results // as they are generated. @@ -435,7 +434,17 @@ With functions: void _processLine(String line) { line = line.trim(); - if (line.isEmpty) return; + if (line.isEmpty) { + return; + } + + // If the line doesn't start with '{', it's not a JSONL object. + // We ignore it to prevent markdown artifacts or other noise from being + // treated as text. + if (!line.startsWith('{')) { + genUiLogger.fine('Ignored non-JSONL line: $line'); + return; + } genUiLogger.fine('Processing line: $line');