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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 189 additions & 0 deletions flutter_app/lib/src/generative_ui_registry.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
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<String, Object?> arguments;
}

class GeneratedUiValidation {
const GeneratedUiValidation._({
this.component,
this.errors = const <String>[],
});

factory GeneratedUiValidation.valid(GeneratedUiComponent component) {
return GeneratedUiValidation._(component: component);
}

factory GeneratedUiValidation.invalid(List<String> errors) {
return GeneratedUiValidation._(errors: List<String>.unmodifiable(errors));
}

final GeneratedUiComponent? component;
final List<String> errors;

bool get isValid => component != null;
}

abstract final class GenerativeUiRegistry {
static GeneratedUiValidation validate(Map<String, Object?> payload) {
final errors = <String>[];
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<String, Object?>.from(rawArguments)
: <String, Object?>{};
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<String, Object?>.unmodifiable(arguments),
),
);
}

static List<String> _validateArguments(
GeneratedComponentKind kind,
Map<String, Object?> arguments,
) {
return switch (kind) {
GeneratedComponentKind.nextAction => _requireStrings(arguments, <String>[
'action_id',
'cta',
]),
GeneratedComponentKind.scheduleSummary => _requireStrings(
arguments,
<String>['source', 'date'],
),
GeneratedComponentKind.routeHint => _requireStrings(arguments, <String>[
'destination',
'mode',
]),
GeneratedComponentKind.deadlineCard => _requireStrings(
arguments,
<String>['course', 'due'],
),
GeneratedComponentKind.quickReply => _requireStrings(arguments, <String>[
'reply',
]),
};
}
}

const List<Map<String, Object?>>
generativeUiFixturePayloads = <Map<String, Object?>>[
<String, Object?>{
'type': 'next_action',
'title': 'Leave for class',
'body':
'Machine Learning starts soon in Room A. Open the schedule before you go.',
'arguments': <String, Object?>{
'action_id': 'open_schedule',
'cta': 'Open schedule',
},
},
<String, Object?>{
'type': 'schedule_summary',
'title': 'Compact day',
'body': 'Two lectures today, with a free study block after lunch.',
'arguments': <String, Object?>{
'source': 'local_timetable',
'date': '2026-07-08',
},
},
<String, Object?>{
'type': 'route_hint',
'title': 'Route hint',
'body': 'Leave in 12 minutes to reach the campus library on time.',
'arguments': <String, Object?>{
'destination': 'Campus Library',
'mode': 'walk',
},
},
<String, Object?>{
'type': 'deadline_card',
'title': 'Deadline tomorrow',
'body': 'Submit the ML exercise sheet before 18:00.',
'arguments': <String, Object?>{
'course': 'Machine Learning',
'due': '2026-07-09T18:00:00',
},
},
<String, Object?>{
'type': 'quick_reply',
'title': 'Quick reply',
'body': 'Ask StudyOS to plan a 45 minute review block.',
'arguments': <String, Object?>{
'reply': 'Plan a 45 minute review block around my next lecture.',
},
},
];

List<String> _requireStrings(
Map<String, Object?> arguments,
List<String> keys,
) {
final errors = <String>[];
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;
}
1 change: 1 addition & 0 deletions flutter_app/lib/src/models.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
3 changes: 3 additions & 0 deletions flutter_app/lib/src/views/home_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(
Expand Down
142 changes: 142 additions & 0 deletions flutter_app/lib/src/widgets/generated_ui_preview_section.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import 'package:flutter/material.dart';

import '../models.dart';
import '../studyos_theme.dart';

class GeneratedUiPreviewSection extends StatefulWidget {
const GeneratedUiPreviewSection({super.key});

@override
State<GeneratedUiPreviewSection> createState() =>
_GeneratedUiPreviewSectionState();
}

class _GeneratedUiPreviewSectionState extends State<GeneratedUiPreviewSection> {
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: <Widget>[
Row(
children: <Widget>[
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: <Widget>[
Icon(_iconFor(component.kind), color: StudyOsColors.accent),
const SizedBox(width: StudyOsSpacing.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
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<String> 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,
),
),
);
}
}
Loading
Loading