From 19aaa66198db08fc5a895291c4086b59ceb880dc Mon Sep 17 00:00:00 2001 From: StudyOS Org Date: Wed, 8 Jul 2026 09:18:23 +0000 Subject: [PATCH 1/2] feat: add bounded generative UI registry --- .../lib/src/generative_ui_registry.dart | 186 ++++++++++++++++++ flutter_app/lib/src/models.dart | 1 + flutter_app/lib/src/views/home_view.dart | 3 + .../widgets/generated_ui_preview_section.dart | 143 ++++++++++++++ .../test/generative_ui_registry_test.dart | 49 +++++ flutter_app/test/navigation_test.dart | 7 + 6 files changed, 389 insertions(+) create mode 100644 flutter_app/lib/src/generative_ui_registry.dart create mode 100644 flutter_app/lib/src/widgets/generated_ui_preview_section.dart create mode 100644 flutter_app/test/generative_ui_registry_test.dart diff --git a/flutter_app/lib/src/generative_ui_registry.dart b/flutter_app/lib/src/generative_ui_registry.dart new file mode 100644 index 0000000..c4dcbec --- /dev/null +++ b/flutter_app/lib/src/generative_ui_registry.dart @@ -0,0 +1,186 @@ +enum GeneratedComponentKind { + nextAction('next_action'), + scheduleSummary('schedule_summary'), + routeHint('route_hint'), + deadlineCard('deadline_card'), + quickReply('quick_reply'); + + const GeneratedComponentKind(this.wireName); + + final String wireName; + + static GeneratedComponentKind? fromWireName(String value) { + for (final kind in GeneratedComponentKind.values) { + if (kind.wireName == value) return kind; + } + return null; + } +} + +class GeneratedUiComponent { + const GeneratedUiComponent({ + required this.kind, + required this.title, + required this.body, + required this.arguments, + }); + + final GeneratedComponentKind kind; + final String title; + final String body; + final Map arguments; +} + +class GeneratedUiValidation { + const GeneratedUiValidation._({this.component, this.errors = const []}); + + factory GeneratedUiValidation.valid(GeneratedUiComponent component) { + return GeneratedUiValidation._(component: component); + } + + factory GeneratedUiValidation.invalid(List errors) { + return GeneratedUiValidation._(errors: List.unmodifiable(errors)); + } + + final GeneratedUiComponent? component; + final List errors; + + bool get isValid => component != null; +} + +abstract final class GenerativeUiRegistry { + static GeneratedUiValidation validate(Map payload) { + final errors = []; + final typeValue = _string(payload['type']); + final kind = typeValue == null + ? null + : GeneratedComponentKind.fromWireName(typeValue); + if (typeValue == null) { + errors.add('Missing string field: type'); + } else if (kind == null) { + errors.add('Unsupported component type: $typeValue'); + } + + final title = _string(payload['title']); + if (title == null) errors.add('Missing string field: title'); + final body = _string(payload['body']); + if (body == null) errors.add('Missing string field: body'); + + final rawArguments = payload['arguments']; + final arguments = rawArguments is Map + ? Map.from(rawArguments) + : {}; + if (rawArguments != null && rawArguments is! Map) { + errors.add('Field arguments must be an object when present'); + } + + if (kind != null) { + errors.addAll(_validateArguments(kind, arguments)); + } + if (errors.isNotEmpty || kind == null || title == null || body == null) { + return GeneratedUiValidation.invalid(errors); + } + return GeneratedUiValidation.valid( + GeneratedUiComponent( + kind: kind, + title: title, + body: body, + arguments: Map.unmodifiable(arguments), + ), + ); + } + + static List _validateArguments( + GeneratedComponentKind kind, + Map arguments, + ) { + return switch (kind) { + GeneratedComponentKind.nextAction => _requireStrings(arguments, [ + 'action_id', + 'cta', + ]), + GeneratedComponentKind.scheduleSummary => _requireStrings( + arguments, + ['source', 'date'], + ), + GeneratedComponentKind.routeHint => _requireStrings(arguments, [ + 'destination', + 'mode', + ]), + GeneratedComponentKind.deadlineCard => _requireStrings(arguments, [ + 'course', + 'due', + ]), + GeneratedComponentKind.quickReply => _requireStrings(arguments, [ + 'reply', + ]), + }; + } +} + +const List> generativeUiFixturePayloads = + >[ + { + 'type': 'next_action', + 'title': 'Leave for class', + 'body': 'Machine Learning starts soon in Room A. Open the schedule before you go.', + 'arguments': { + 'action_id': 'open_schedule', + 'cta': 'Open schedule', + }, + }, + { + 'type': 'schedule_summary', + 'title': 'Compact day', + 'body': 'Two lectures today, with a free study block after lunch.', + 'arguments': { + 'source': 'local_timetable', + 'date': '2026-07-08', + }, + }, + { + 'type': 'route_hint', + 'title': 'Route hint', + 'body': 'Leave in 12 minutes to reach the campus library on time.', + 'arguments': { + 'destination': 'Campus Library', + 'mode': 'walk', + }, + }, + { + 'type': 'deadline_card', + 'title': 'Deadline tomorrow', + 'body': 'Submit the ML exercise sheet before 18:00.', + 'arguments': { + 'course': 'Machine Learning', + 'due': '2026-07-09T18:00:00', + }, + }, + { + 'type': 'quick_reply', + 'title': 'Quick reply', + 'body': 'Ask StudyOS to plan a 45 minute review block.', + 'arguments': { + 'reply': 'Plan a 45 minute review block around my next lecture.', + }, + }, + ]; + +List _requireStrings( + Map arguments, + List keys, +) { + final errors = []; + for (final key in keys) { + if (_string(arguments[key]) == null) { + errors.add('Missing string argument: $key'); + } + } + return errors; +} + +String? _string(Object? value) { + final text = value?.toString().trim(); + return text == null || text.isEmpty ? null : text; +} + diff --git a/flutter_app/lib/src/models.dart b/flutter_app/lib/src/models.dart index 60a9720..7a110cb 100644 --- a/flutter_app/lib/src/models.dart +++ b/flutter_app/lib/src/models.dart @@ -6,6 +6,7 @@ import 'tool_trace.dart'; export 'student_profile.dart'; export 'mail_models.dart'; export 'feed_summary.dart'; +export 'generative_ui_registry.dart'; export 'timetable_models.dart'; export 'tool_trace.dart'; diff --git a/flutter_app/lib/src/views/home_view.dart b/flutter_app/lib/src/views/home_view.dart index 23e07b0..f867f75 100644 --- a/flutter_app/lib/src/views/home_view.dart +++ b/flutter_app/lib/src/views/home_view.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../assistant_copy.dart'; import '../models.dart'; import '../studyos_theme.dart'; +import '../widgets/generated_ui_preview_section.dart'; import '../widgets/proactive_feed_section.dart'; class HomeView extends StatelessWidget { @@ -50,6 +51,8 @@ class HomeView extends StatelessWidget { const SizedBox(height: StudyOsSpacing.lg), ProactiveFeedSection(snapshot: snapshot, onRefresh: onRefresh), const SizedBox(height: StudyOsSpacing.xl), + const GeneratedUiPreviewSection(), + const SizedBox(height: StudyOsSpacing.xl), _StatusGrid( items: <_HomeStatusItem>[ _HomeStatusItem( diff --git a/flutter_app/lib/src/widgets/generated_ui_preview_section.dart b/flutter_app/lib/src/widgets/generated_ui_preview_section.dart new file mode 100644 index 0000000..b3717cc --- /dev/null +++ b/flutter_app/lib/src/widgets/generated_ui_preview_section.dart @@ -0,0 +1,143 @@ +import 'package:flutter/material.dart'; + +import '../models.dart'; +import '../studyos_theme.dart'; + +class GeneratedUiPreviewSection extends StatefulWidget { + const GeneratedUiPreviewSection({super.key}); + + @override + State createState() => + _GeneratedUiPreviewSectionState(); +} + +class _GeneratedUiPreviewSectionState extends State { + int _selectedIndex = 0; + + @override + Widget build(BuildContext context) { + final validations = generativeUiFixturePayloads + .map(GenerativeUiRegistry.validate) + .toList(growable: false); + final validation = validations[_selectedIndex % validations.length]; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Generated component preview', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + IconButton( + tooltip: 'Previous component', + onPressed: () => _select(_selectedIndex - 1, validations.length), + icon: const Icon(Icons.chevron_left_rounded), + ), + IconButton( + tooltip: 'Next component', + onPressed: () => _select(_selectedIndex + 1, validations.length), + icon: const Icon(Icons.chevron_right_rounded), + ), + ], + ), + const SizedBox(height: StudyOsSpacing.sm), + if (validation.component case final component?) + _GeneratedComponentCard(component: component) + else + _InvalidComponentCard(errors: validation.errors), + ], + ); + } + + void _select(int index, int length) { + setState(() => _selectedIndex = (index + length) % length); + } +} + +class _GeneratedComponentCard extends StatelessWidget { + const _GeneratedComponentCard({required this.component}); + + final GeneratedUiComponent component; + + @override + Widget build(BuildContext context) { + return Material( + color: StudyOsColors.surface, + shape: RoundedRectangleBorder( + side: const BorderSide(color: StudyOsColors.border), + borderRadius: BorderRadius.circular(StudyOsRadii.md), + ), + child: Padding( + padding: const EdgeInsets.all(StudyOsSpacing.lg), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(_iconFor(component.kind), color: StudyOsColors.accent), + const SizedBox(width: StudyOsSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + component.title, + style: Theme.of(context).textTheme.labelLarge, + ), + const SizedBox(height: StudyOsSpacing.xs), + Text( + component.body, + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: StudyOsSpacing.sm), + Text( + component.kind.wireName, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: StudyOsColors.success, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + IconData _iconFor(GeneratedComponentKind kind) { + return switch (kind) { + GeneratedComponentKind.nextAction => Icons.arrow_forward_rounded, + GeneratedComponentKind.scheduleSummary => Icons.calendar_month_outlined, + GeneratedComponentKind.routeHint => Icons.map_outlined, + GeneratedComponentKind.deadlineCard => Icons.assignment_late_outlined, + GeneratedComponentKind.quickReply => Icons.quickreply_outlined, + }; + } +} + +class _InvalidComponentCard extends StatelessWidget { + const _InvalidComponentCard({required this.errors}); + + final List errors; + + @override + Widget build(BuildContext context) { + return Material( + color: StudyOsColors.surface, + shape: RoundedRectangleBorder( + side: const BorderSide(color: StudyOsColors.warning), + borderRadius: BorderRadius.circular(StudyOsRadii.md), + ), + child: Padding( + padding: const EdgeInsets.all(StudyOsSpacing.lg), + child: Text( + errors.join('\n'), + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ); + } +} + diff --git a/flutter_app/test/generative_ui_registry_test.dart b/flutter_app/test/generative_ui_registry_test.dart new file mode 100644 index 0000000..5b29081 --- /dev/null +++ b/flutter_app/test/generative_ui_registry_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:studyos_agent/src/models.dart'; + +void main() { + test('fixture payloads validate for every supported component kind', () { + final validatedKinds = {}; + + for (final payload in generativeUiFixturePayloads) { + final validation = GenerativeUiRegistry.validate(payload); + expect(validation.errors, isEmpty); + expect(validation.component, isNotNull); + validatedKinds.add(validation.component!.kind); + } + + expect(validatedKinds, GeneratedComponentKind.values.toSet()); + }); + + test('registry rejects unknown component types and missing arguments', () { + final unknown = GenerativeUiRegistry.validate({ + 'type': 'freeform_screen', + 'title': 'Unsafe', + 'body': 'Do anything', + }); + expect(unknown.isValid, isFalse); + expect(unknown.errors.single, contains('Unsupported component type')); + + final missingArgument = GenerativeUiRegistry.validate({ + 'type': 'route_hint', + 'title': 'Route', + 'body': 'Leave now', + 'arguments': {'destination': 'Library'}, + }); + expect(missingArgument.isValid, isFalse); + expect(missingArgument.errors, contains('Missing string argument: mode')); + }); + + test('registry rejects non-object arguments', () { + final validation = GenerativeUiRegistry.validate({ + 'type': 'quick_reply', + 'title': 'Reply', + 'body': 'Ask', + 'arguments': 'reply=hello', + }); + + expect(validation.isValid, isFalse); + expect(validation.errors, contains('Field arguments must be an object when present')); + }); +} + diff --git a/flutter_app/test/navigation_test.dart b/flutter_app/test/navigation_test.dart index d6e0999..44b292d 100644 --- a/flutter_app/test/navigation_test.dart +++ b/flutter_app/test/navigation_test.dart @@ -283,6 +283,13 @@ void main() { expect(find.text('Set up your StudyOS'), findsOneWidget); expect(find.textContaining('Connect your profile'), findsOneWidget); expect(find.text('Timetable: Unavailable'), findsOneWidget); + expect(find.text('Generated component preview'), findsOneWidget); + expect(find.text('Leave for class'), findsOneWidget); + + await tester.tap(find.byTooltip('Next component')); + await tester.pump(); + + expect(find.text('Compact day'), findsOneWidget); expect(find.byType(RefreshIndicator), findsOneWidget); }); From 950e7b73d039f8665c8805d6ab6d3301a989badb Mon Sep 17 00:00:00 2001 From: SebastianBoehler <27767932+SebastianBoehler@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:59:53 +0200 Subject: [PATCH 2/2] chore(format): apply dart formatter to generated UI --- .../lib/src/generative_ui_registry.dart | 109 +++++++++--------- .../widgets/generated_ui_preview_section.dart | 1 - .../test/generative_ui_registry_test.dart | 6 +- 3 files changed, 60 insertions(+), 56 deletions(-) diff --git a/flutter_app/lib/src/generative_ui_registry.dart b/flutter_app/lib/src/generative_ui_registry.dart index c4dcbec..b0fd520 100644 --- a/flutter_app/lib/src/generative_ui_registry.dart +++ b/flutter_app/lib/src/generative_ui_registry.dart @@ -32,7 +32,10 @@ class GeneratedUiComponent { } class GeneratedUiValidation { - const GeneratedUiValidation._({this.component, this.errors = const []}); + const GeneratedUiValidation._({ + this.component, + this.errors = const [], + }); factory GeneratedUiValidation.valid(GeneratedUiComponent component) { return GeneratedUiValidation._(component: component); @@ -107,10 +110,10 @@ abstract final class GenerativeUiRegistry { 'destination', 'mode', ]), - GeneratedComponentKind.deadlineCard => _requireStrings(arguments, [ - 'course', - 'due', - ]), + GeneratedComponentKind.deadlineCard => _requireStrings( + arguments, + ['course', 'due'], + ), GeneratedComponentKind.quickReply => _requireStrings(arguments, [ 'reply', ]), @@ -118,53 +121,54 @@ abstract final class GenerativeUiRegistry { } } -const List> generativeUiFixturePayloads = - >[ - { - 'type': 'next_action', - 'title': 'Leave for class', - 'body': 'Machine Learning starts soon in Room A. Open the schedule before you go.', - 'arguments': { - 'action_id': 'open_schedule', - 'cta': 'Open schedule', - }, - }, - { - 'type': 'schedule_summary', - 'title': 'Compact day', - 'body': 'Two lectures today, with a free study block after lunch.', - 'arguments': { - 'source': 'local_timetable', - 'date': '2026-07-08', - }, - }, - { - 'type': 'route_hint', - 'title': 'Route hint', - 'body': 'Leave in 12 minutes to reach the campus library on time.', - 'arguments': { - 'destination': 'Campus Library', - 'mode': 'walk', - }, - }, - { - 'type': 'deadline_card', - 'title': 'Deadline tomorrow', - 'body': 'Submit the ML exercise sheet before 18:00.', - 'arguments': { - 'course': 'Machine Learning', - 'due': '2026-07-09T18:00:00', - }, - }, - { - 'type': 'quick_reply', - 'title': 'Quick reply', - 'body': 'Ask StudyOS to plan a 45 minute review block.', - 'arguments': { - 'reply': 'Plan a 45 minute review block around my next lecture.', - }, - }, - ]; +const List> +generativeUiFixturePayloads = >[ + { + 'type': 'next_action', + 'title': 'Leave for class', + 'body': + 'Machine Learning starts soon in Room A. Open the schedule before you go.', + 'arguments': { + 'action_id': 'open_schedule', + 'cta': 'Open schedule', + }, + }, + { + 'type': 'schedule_summary', + 'title': 'Compact day', + 'body': 'Two lectures today, with a free study block after lunch.', + 'arguments': { + 'source': 'local_timetable', + 'date': '2026-07-08', + }, + }, + { + 'type': 'route_hint', + 'title': 'Route hint', + 'body': 'Leave in 12 minutes to reach the campus library on time.', + 'arguments': { + 'destination': 'Campus Library', + 'mode': 'walk', + }, + }, + { + 'type': 'deadline_card', + 'title': 'Deadline tomorrow', + 'body': 'Submit the ML exercise sheet before 18:00.', + 'arguments': { + 'course': 'Machine Learning', + 'due': '2026-07-09T18:00:00', + }, + }, + { + 'type': 'quick_reply', + 'title': 'Quick reply', + 'body': 'Ask StudyOS to plan a 45 minute review block.', + 'arguments': { + 'reply': 'Plan a 45 minute review block around my next lecture.', + }, + }, +]; List _requireStrings( Map arguments, @@ -183,4 +187,3 @@ String? _string(Object? value) { final text = value?.toString().trim(); return text == null || text.isEmpty ? null : text; } - diff --git a/flutter_app/lib/src/widgets/generated_ui_preview_section.dart b/flutter_app/lib/src/widgets/generated_ui_preview_section.dart index b3717cc..7e292b5 100644 --- a/flutter_app/lib/src/widgets/generated_ui_preview_section.dart +++ b/flutter_app/lib/src/widgets/generated_ui_preview_section.dart @@ -140,4 +140,3 @@ class _InvalidComponentCard extends StatelessWidget { ); } } - diff --git a/flutter_app/test/generative_ui_registry_test.dart b/flutter_app/test/generative_ui_registry_test.dart index 5b29081..7e2ae8b 100644 --- a/flutter_app/test/generative_ui_registry_test.dart +++ b/flutter_app/test/generative_ui_registry_test.dart @@ -43,7 +43,9 @@ void main() { }); expect(validation.isValid, isFalse); - expect(validation.errors, contains('Field arguments must be an object when present')); + expect( + validation.errors, + contains('Field arguments must be an object when present'), + ); }); } -