example;
+}
+
+/// The four-in-one contract for an A2UI component: it names itself, parses its
+/// own props into a typed record, builds itself from that record, and documents
+/// itself for the prompt.
+///
+/// Because all four live on one object, the vocabulary advertised to the model,
+/// the shapes accepted by the parser and the shapes consumed by the renderer
+/// cannot drift apart.
+abstract class A2UiSpec {
+ const A2UiSpec();
+
+ /// Canonical component name as it appears in JSON, e.g. `StatCard`.
+ String get name;
+
+ /// Additional names accepted for this component. Matching is case- and
+ /// separator-insensitive, so only semantically distinct spellings belong here.
+ List get aliases => const [];
+
+ A2UiDoc get doc;
+
+ /// Converts a node into a typed props record.
+ ///
+ /// Implementations MUST NOT throw and MUST NOT return null — degrade to
+ /// documented fallbacks instead. Deciding whether a payload is UI at all is
+ /// the parser's job, not this method's.
+ P parseProps(A2UiNode node);
+
+ // `buildWidget`/`render` deliberately keep positional arguments rather than
+ // named ones: this is a build-style API (context, then the thing being
+ // built, then ambient config), mirroring Flutter's own `Widget
+ // build(BuildContext context)` convention that every implementation and
+ // call site in this codebase already follows. Every implementation is a
+ // one-line override, so argument-order mistakes surface immediately as a
+ // type error rather than silently compiling wrong — named parameters would
+ // add call-site noise without a corresponding safety win here.
+ Widget buildWidget(BuildContext context, P props, A2UiTheme theme);
+
+ /// Type-erased entry point used by the renderer.
+ Widget render(BuildContext context, A2UiNode node, A2UiTheme theme) =>
+ buildWidget(context, parseProps(node), theme);
+}
diff --git a/workout-logger/lib/genui/src/a2ui_theme.dart b/workout-logger/lib/genui/src/a2ui_theme.dart
new file mode 100644
index 0000000..1611352
--- /dev/null
+++ b/workout-logger/lib/genui/src/a2ui_theme.dart
@@ -0,0 +1,99 @@
+import 'package:flutter/widgets.dart';
+
+/// Visual tokens the A2UI renderer draws with.
+///
+/// Injected rather than imported so `lib/genui/` carries no dependency on any
+/// particular app's design system.
+@immutable
+class A2UiTheme {
+ const A2UiTheme({
+ required this.surface,
+ required this.border,
+ required this.divider,
+ required this.textPrimary,
+ required this.textSoft,
+ required this.textMuted,
+ required this.textFaint,
+ required this.accent,
+ required this.positive,
+ required this.negative,
+ required this.seriesPalette,
+ required this.spacing,
+ required this.radius,
+ required this.pillRadius,
+ });
+
+ final Color surface;
+ final Color border;
+ final Color divider;
+ final Color textPrimary;
+ final Color textSoft;
+ final Color textMuted;
+ final Color textFaint;
+ final Color accent;
+ final Color positive;
+ final Color negative;
+ final List seriesPalette;
+ final double spacing;
+ final double radius;
+ final double pillRadius;
+
+ /// Colour for series index [i], cycling through [seriesPalette].
+ Color seriesColor(int i) {
+ assert(
+ seriesPalette.isNotEmpty,
+ 'seriesPalette must not be empty — seriesColor() indexes into it '
+ 'with a modulo, which throws on an empty list.',
+ );
+ if (seriesPalette.isEmpty) return accent;
+ return seriesPalette[i % seriesPalette.length];
+ }
+
+ /// Neutral dark default so the package renders standalone.
+ static const A2UiTheme dark = A2UiTheme(
+ surface: Color(0xFF11111A),
+ border: Color(0x12FFFFFF),
+ divider: Color(0x0FFFFFFF),
+ textPrimary: Color(0xFFF4F4F8),
+ textSoft: Color(0xB8F4F4F8),
+ textMuted: Color(0x7AF4F4F8),
+ textFaint: Color(0x52F4F4F8),
+ accent: Color(0xFF7C3AED),
+ positive: Color(0xFF00C89B),
+ negative: Color(0xFFE05040),
+ seriesPalette: [
+ Color(0xFF7C3AED),
+ Color(0xFF00C2D4),
+ Color(0xFF00C89B),
+ Color(0xFFDBA520),
+ Color(0xFFE05040),
+ ],
+ spacing: 16,
+ radius: 16,
+ pillRadius: 999,
+ );
+}
+
+/// Supplies an [A2UiTheme] to the renderer subtree.
+///
+/// Absent a provider, [of] returns [A2UiTheme.dark] so the package renders
+/// standalone in tests and previews.
+class A2UiThemeProvider extends InheritedWidget {
+ const A2UiThemeProvider({
+ super.key,
+ required this.theme,
+ required super.child,
+ });
+
+ final A2UiTheme theme;
+
+ static A2UiTheme of(BuildContext context) =>
+ context
+ .dependOnInheritedWidgetOfExactType()
+ ?.theme ??
+ A2UiTheme.dark;
+
+ @override
+ bool updateShouldNotify(A2UiThemeProvider oldWidget) =>
+ oldWidget.theme != theme;
+}
diff --git a/workout-logger/lib/genui/src/components/data_list_group.dart b/workout-logger/lib/genui/src/components/data_list_group.dart
new file mode 100644
index 0000000..a2db1d0
--- /dev/null
+++ b/workout-logger/lib/genui/src/components/data_list_group.dart
@@ -0,0 +1,241 @@
+import 'package:flutter/material.dart';
+
+import '../a2ui_node.dart';
+import '../a2ui_panels.dart';
+import '../a2ui_props.dart';
+import '../a2ui_spec.dart';
+import '../a2ui_theme.dart';
+
+@immutable
+class A2UiListRow {
+ const A2UiListRow({
+ required this.primaryText,
+ this.secondaryText,
+ this.trailingValue,
+ });
+
+ final String primaryText;
+ final String? secondaryText;
+ final String? trailingValue;
+}
+
+@immutable
+class DataListGroupProps {
+ const DataListGroupProps({required this.rows, this.title});
+
+ /// Null renders no header — the old code cast this to a non-null String.
+ final String? title;
+ final List rows;
+
+ bool get hasData => rows.isNotEmpty;
+}
+
+/// A titled list of primary / secondary / trailing rows.
+class DataListGroupSpec extends A2UiSpec {
+ const DataListGroupSpec();
+
+ @override
+ String get name => 'DataListGroup';
+
+ @override
+ List get aliases => const ['DataList', 'ListGroup', 'Table', 'List'];
+
+ @override
+ A2UiDoc get doc => const A2UiDoc(
+ schema: 'DataListGroup {title?, items: '
+ '[{primaryText, secondaryText?, trailingValue?}]}',
+ purpose:
+ 'A short ranked or dated list. Use for records, recent sessions '
+ 'and top-N breakdowns.',
+ example: {
+ 'component': 'DataListGroup',
+ 'props': {
+ 'title': 'Recent Personal Records',
+ 'items': [
+ {
+ 'primaryText': 'Bench Press',
+ 'secondaryText': '2026-07-04',
+ 'trailingValue': '102.5 kg',
+ },
+ {
+ 'primaryText': 'Back Squat',
+ 'secondaryText': '2026-06-28',
+ 'trailingValue': '140 kg',
+ },
+ ],
+ },
+ },
+ );
+
+ @override
+ DataListGroupProps parseProps(A2UiNode node) {
+ final p = node.props;
+ final title = p.textOrNull('title');
+
+ final rows = [];
+ final raw = p.lookup('items');
+ if (raw is List) {
+ for (final item in raw) {
+ final row = _row(item);
+ if (row != null) rows.add(row);
+ }
+ }
+
+ return DataListGroupProps(
+ title: (title == null || title.isEmpty) ? null : title,
+ rows: rows,
+ );
+ }
+
+ /// Builds a row from a map or a bare scalar, or returns null when the item
+ /// carries nothing displayable.
+ A2UiListRow? _row(Object? item) {
+ if (item is String || item is num || item is bool) {
+ return A2UiListRow(primaryText: item.toString());
+ }
+ if (item is! Map) return null;
+
+ final props = A2UiProps(A2UiProps.stringKeyed(item));
+ var primary = props.textOrNull('primaryText');
+
+ // Last resort: the first value in the map that stringifies, so a row keyed
+ // with unexpected names still shows something.
+ if (primary == null || primary.isEmpty) {
+ for (final value in props.raw.values) {
+ if (value is String && value.isNotEmpty) {
+ primary = value;
+ break;
+ }
+ if (value is num || value is bool) {
+ primary = value.toString();
+ break;
+ }
+ }
+ }
+ if (primary == null || primary.isEmpty) return null;
+
+ final secondary = props.textOrNull('secondaryText');
+ final trailing = props.textOrNull('trailingValue');
+
+ return A2UiListRow(
+ primaryText: primary,
+ secondaryText:
+ (secondary == null || secondary.isEmpty || secondary == primary)
+ ? null
+ : secondary,
+ trailingValue: (trailing == null || trailing.isEmpty || trailing == primary)
+ ? null
+ : trailing,
+ );
+ }
+
+ @override
+ Widget buildWidget(
+ BuildContext context,
+ DataListGroupProps props,
+ A2UiTheme theme,
+ ) {
+ if (!props.hasData) {
+ return A2UiEmptyPanel(
+ message: '${props.title ?? 'List'}: No items available',
+ theme: theme,
+ );
+ }
+
+ return A2UiPanel(
+ theme: theme,
+ padded: false,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ if (props.title case final String title)
+ Padding(
+ padding: EdgeInsets.all(theme.spacing),
+ child: Text(
+ title,
+ style: TextStyle(
+ color: theme.textPrimary,
+ fontSize: 14,
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ),
+ for (var i = 0; i < props.rows.length; i++)
+ _Row(
+ row: props.rows[i],
+ theme: theme,
+ showDivider: i < props.rows.length - 1,
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _Row extends StatelessWidget {
+ const _Row({
+ required this.row,
+ required this.theme,
+ required this.showDivider,
+ });
+
+ final A2UiListRow row;
+ final A2UiTheme theme;
+ final bool showDivider;
+
+ @override
+ Widget build(BuildContext context) => Container(
+ padding: EdgeInsets.symmetric(
+ horizontal: theme.spacing,
+ vertical: theme.spacing / 2 + 2,
+ ),
+ decoration: BoxDecoration(
+ border: showDivider
+ ? Border(bottom: BorderSide(color: theme.divider))
+ : null,
+ ),
+ child: Row(
+ children: [
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ row.primaryText,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ color: theme.textPrimary,
+ fontSize: 13,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ if (row.secondaryText case final String secondary) ...[
+ const SizedBox(height: 2),
+ Text(
+ secondary,
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(color: theme.textMuted, fontSize: 11),
+ ),
+ ],
+ ],
+ ),
+ ),
+ if (row.trailingValue case final String trailing) ...[
+ SizedBox(width: theme.spacing / 2),
+ Text(
+ trailing,
+ style: TextStyle(
+ color: theme.seriesColor(1),
+ fontSize: 12,
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ],
+ ],
+ ),
+ );
+}
diff --git a/workout-logger/lib/genui/src/components/dynamic_chart.dart b/workout-logger/lib/genui/src/components/dynamic_chart.dart
new file mode 100644
index 0000000..961d8e1
--- /dev/null
+++ b/workout-logger/lib/genui/src/components/dynamic_chart.dart
@@ -0,0 +1,370 @@
+import 'package:fl_chart/fl_chart.dart';
+import 'package:flutter/material.dart';
+
+import '../a2ui_node.dart';
+import '../a2ui_panels.dart';
+import '../a2ui_series.dart';
+import '../a2ui_spec.dart';
+import '../a2ui_theme.dart';
+
+enum A2UiChartType {
+ line,
+ bar,
+ pie;
+
+ /// Normalizes separators and common model spellings (`LineChart`,
+ /// `bar_chart`, `donut`) onto the three supported types, defaulting to line.
+ static A2UiChartType parse(String? raw) {
+ final t = raw?.toLowerCase().replaceAll(RegExp(r'[\s_\-]'), '') ?? '';
+ if (t.contains('pie') || t.contains('donut') || t.contains('doughnut')) {
+ return A2UiChartType.pie;
+ }
+ if (t.contains('bar') || t.contains('column') || t.contains('histogram')) {
+ return A2UiChartType.bar;
+ }
+ return A2UiChartType.line;
+ }
+}
+
+@immutable
+class DynamicChartProps {
+ const DynamicChartProps({
+ required this.title,
+ required this.type,
+ required this.labels,
+ required this.series,
+ this.subtitle,
+ });
+
+ final String title;
+ final String? subtitle;
+ final A2UiChartType type;
+
+ /// Always at least as long as the longest series, padded with empty strings,
+ /// so axis label lookup by index can never go out of range.
+ final List labels;
+ final List series;
+
+ bool get hasData => series.isNotEmpty;
+}
+
+/// Line, bar or pie over the shared `{labels, series}` shape.
+class DynamicChartSpec extends A2UiSpec {
+ const DynamicChartSpec();
+
+ @override
+ String get name => 'DynamicChart';
+
+ @override
+ List get aliases => const [
+ 'Chart',
+ 'LineChart',
+ 'BarChart',
+ 'PieChart',
+ 'TimeSeries',
+ ];
+
+ @override
+ A2UiDoc get doc => const A2UiDoc(
+ schema: 'DynamicChart {type: line|bar|pie, title, labels: [string], '
+ 'series: [{name, values: [number]}]} '
+ '// or values: [number] for a single series',
+ purpose:
+ 'Trends over time (line), category comparisons (bar), or a share '
+ 'breakdown (pie). Use multiple series to compare.',
+ example: {
+ 'component': 'DynamicChart',
+ 'props': {
+ 'type': 'line',
+ 'title': 'Biceps vs Triceps Volume',
+ 'labels': ['07-06', '07-09', '07-12'],
+ 'series': [
+ {'name': 'Biceps', 'values': [640, 720, 810]},
+ {'name': 'Triceps', 'values': [1200, 1150, 1290]},
+ ],
+ },
+ },
+ );
+
+ @override
+ DynamicChartProps parseProps(A2UiNode node) {
+ final p = node.props;
+ final title = p.text('title', or: 'Chart');
+ final series = A2UiSeries.extract(p, fallbackName: title);
+
+ var longest = 0;
+ for (final s in series) {
+ if (s.values.length > longest) longest = s.values.length;
+ }
+ final labels = p.stringList('labels');
+ final padded = [
+ ...labels,
+ for (var i = labels.length; i < longest; i++) '',
+ ];
+
+ final subtitle = p.textOrNull('subtitle');
+
+ return DynamicChartProps(
+ title: title,
+ subtitle: (subtitle == null || subtitle.isEmpty) ? null : subtitle,
+ type: A2UiChartType.parse(p.textOrNull('type')),
+ labels: padded,
+ series: series,
+ );
+ }
+
+ @override
+ Widget buildWidget(
+ BuildContext context,
+ DynamicChartProps props,
+ A2UiTheme theme,
+ ) {
+ if (!props.hasData) {
+ return A2UiEmptyPanel(
+ message: '${props.title}: No chart data available',
+ theme: theme,
+ );
+ }
+
+ final showLegend =
+ props.series.length > 1 && props.type != A2UiChartType.pie;
+
+ return A2UiPanel(
+ theme: theme,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ A2UiPanelTitle(
+ title: props.title,
+ trailing: props.type == A2UiChartType.pie ? props.subtitle : null,
+ theme: theme,
+ ),
+ if (showLegend) ...[
+ const SizedBox(height: 6),
+ A2UiLegend(
+ names: [for (final s in props.series) s.name],
+ theme: theme,
+ ),
+ ],
+ SizedBox(height: theme.spacing),
+ SizedBox(
+ height: 195,
+ child: switch (props.type) {
+ A2UiChartType.bar => _bar(props, theme),
+ A2UiChartType.pie => _pie(props, theme),
+ A2UiChartType.line => _line(props, theme),
+ },
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _line(DynamicChartProps props, A2UiTheme theme) {
+ final (minY, maxY) = _yBounds(props.series);
+ return LineChart(
+ LineChartData(
+ minY: minY,
+ maxY: maxY,
+ gridData: a2uiGridData(theme),
+ borderData: FlBorderData(show: false),
+ titlesData: a2uiTitlesData(props.labels, theme),
+ lineBarsData: [
+ for (var i = 0; i < props.series.length; i++)
+ LineChartBarData(
+ spots: [
+ for (var x = 0; x < props.series[i].values.length; x++)
+ FlSpot(x.toDouble(), props.series[i].values[x]),
+ ],
+ isCurved: true,
+ color: theme.seriesColor(i),
+ barWidth: 3,
+ dotData: FlDotData(show: props.series[i].values.length < 10),
+ belowBarData: BarAreaData(
+ show: props.series.length == 1,
+ color: theme.seriesColor(i).withValues(alpha: 0.12),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _bar(DynamicChartProps props, A2UiTheme theme) {
+ final (minY, maxY) = _yBounds(props.series);
+ return BarChart(
+ BarChartData(
+ minY: minY,
+ maxY: maxY,
+ gridData: a2uiGridData(theme),
+ borderData: FlBorderData(show: false),
+ titlesData: a2uiTitlesData(props.labels, theme),
+ barGroups: [
+ for (var group = 0; group < props.labels.length; group++)
+ BarChartGroupData(
+ x: group,
+ barRods: [
+ for (var i = 0; i < props.series.length; i++)
+ if (group < props.series[i].values.length)
+ BarChartRodData(
+ toY: props.series[i].values[group],
+ width: props.series.length > 1 ? 8 : 14,
+ borderRadius: BorderRadius.circular(6),
+ color: theme.seriesColor(i),
+ ),
+ ],
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _pie(DynamicChartProps props, A2UiTheme theme) {
+ final rawValues = props.series.first.values;
+ // A pie slice needs a positive share of the whole; negative or zero
+ // entries have no geometric meaning. Filter them out, but keep each
+ // surviving entry's ORIGINAL index so theme.seriesColor(i) and
+ // props.labels[i] — both indexed by original position — stay aligned.
+ final positive = [
+ for (var i = 0; i < rawValues.length; i++)
+ if (rawValues[i] > 0) i,
+ ];
+ if (positive.isEmpty) {
+ return A2UiEmptyPanel(
+ message: '${props.title}: No positive values to chart',
+ theme: theme,
+ );
+ }
+ final total = positive.fold(0, (sum, i) => sum + rawValues[i]);
+
+ return Row(
+ children: [
+ Expanded(
+ child: PieChart(
+ PieChartData(
+ sectionsSpace: 2,
+ centerSpaceRadius: 32,
+ sections: [
+ for (final i in positive)
+ PieChartSectionData(
+ value: rawValues[i],
+ color: theme.seriesColor(i),
+ radius: 44,
+ title: '${(rawValues[i] / total * 100).round()}%',
+ titleStyle: TextStyle(
+ color: theme.textPrimary,
+ fontSize: 11,
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ SizedBox(width: theme.spacing / 2),
+ Expanded(
+ child: SingleChildScrollView(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ for (final i in positive)
+ Padding(
+ padding: const EdgeInsets.only(bottom: 6),
+ child: Row(
+ children: [
+ Container(
+ width: 8,
+ height: 8,
+ decoration: BoxDecoration(
+ color: theme.seriesColor(i),
+ shape: BoxShape.circle,
+ ),
+ ),
+ const SizedBox(width: 6),
+ Expanded(
+ child: Text(
+ '${i < props.labels.length ? props.labels[i] : ''} '
+ '(${rawValues[i].round()})',
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style:
+ TextStyle(color: theme.textMuted, fontSize: 11),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ );
+ }
+}
+
+/// Y-axis bounds for [series], shared by `_line` and `_bar` so both charts
+/// agree on the same visible range.
+///
+/// When every value is non-negative, the axis starts at 0 (existing
+/// behavior), with a 15% headroom margin above the max — clamped to a
+/// minimum span of 1 so an all-zero series doesn't collapse to a
+/// zero-height axis.
+///
+/// When any value is negative, both bounds are derived from the true min
+/// and max (via [A2UiSeries.minValue]/[A2UiSeries.maxValue], which return
+/// real negative extrema rather than clamping to 0) so every data point —
+/// including an all-negative series — falls within the visible range with
+/// a margin, instead of silently rendering off-chart.
+(double, double) _yBounds(List series) {
+ final max = A2UiSeries.maxValue(series);
+ final min = A2UiSeries.minValue(series);
+ if (min >= 0) {
+ return (0, max <= 0 ? 1 : max * 1.15);
+ }
+ final minY = min * 1.15;
+ final maxY = max <= 0 ? max * 0.85 : max * 1.15;
+ return (minY, maxY);
+}
+
+/// Horizontal-only grid lines in the theme's border colour.
+FlGridData a2uiGridData(A2UiTheme theme) => FlGridData(
+ show: true,
+ drawVerticalLine: false,
+ getDrawingHorizontalLine: (_) =>
+ FlLine(color: theme.border, strokeWidth: 1),
+ );
+
+/// Bottom axis labelled from [labels] by index, with a bounds check so an
+/// out-of-range tick renders nothing rather than throwing.
+FlTitlesData a2uiTitlesData(List labels, A2UiTheme theme) =>
+ FlTitlesData(
+ topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
+ rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
+ leftTitles: const AxisTitles(
+ sideTitles: SideTitles(showTitles: true, reservedSize: 34),
+ ),
+ bottomTitles: AxisTitles(
+ sideTitles: SideTitles(
+ showTitles: true,
+ reservedSize: 30,
+ getTitlesWidget: (value, meta) {
+ final index = value.round();
+ if (index < 0 || index >= labels.length) {
+ return const SizedBox.shrink();
+ }
+ return Padding(
+ padding: const EdgeInsets.only(top: 6),
+ child: Text(
+ labels[index],
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(color: theme.textFaint, fontSize: 10),
+ ),
+ );
+ },
+ ),
+ ),
+ );
diff --git a/workout-logger/lib/genui/src/components/filter_chips.dart b/workout-logger/lib/genui/src/components/filter_chips.dart
new file mode 100644
index 0000000..7c09fcf
--- /dev/null
+++ b/workout-logger/lib/genui/src/components/filter_chips.dart
@@ -0,0 +1,129 @@
+import 'package:flutter/material.dart';
+
+import '../a2ui_node.dart';
+import '../a2ui_spec.dart';
+import '../a2ui_theme.dart';
+
+@immutable
+class FilterChipsProps {
+ const FilterChipsProps({required this.options, this.activeOption});
+
+ final List options;
+
+ /// Null when the model omitted it or named an option that does not exist.
+ /// The old renderer cast this to a non-null String and crashed.
+ final String? activeOption;
+
+ bool get hasData => options.isNotEmpty;
+}
+
+/// A decorative row of context chips showing the window a dashboard covers.
+///
+/// Deliberately non-interactive: A2UI has no action contract yet, so a tappable
+/// chip would imply behaviour the renderer cannot deliver. Adding interactivity
+/// means threading an `onAction` callback through `A2UiRenderer` first.
+class FilterChipsSpec extends A2UiSpec {
+ const FilterChipsSpec();
+
+ @override
+ String get name => 'FilterChips';
+
+ @override
+ List get aliases => const ['Chips', 'FilterRow', 'Tags'];
+
+ @override
+ A2UiDoc get doc => const A2UiDoc(
+ schema: 'FilterChips {options: [string], activeOption?}',
+ purpose:
+ 'Labels the window or scope a dashboard covers. Decorative — the '
+ 'chips are not tappable.',
+ example: {
+ 'component': 'FilterChips',
+ 'props': {
+ 'options': ['7 days', '30 days', '90 days'],
+ 'activeOption': '30 days',
+ },
+ },
+ );
+
+ @override
+ FilterChipsProps parseProps(A2UiNode node) {
+ final p = node.props;
+ final options = p.stringList('options');
+ final requested = p.textOrNull('activeOption');
+
+ String? active;
+ if (requested != null) {
+ for (final option in options) {
+ if (option.toLowerCase() == requested.toLowerCase()) {
+ active = option;
+ break;
+ }
+ }
+ }
+
+ return FilterChipsProps(options: options, activeOption: active);
+ }
+
+ @override
+ Widget buildWidget(
+ BuildContext context,
+ FilterChipsProps props,
+ A2UiTheme theme,
+ ) {
+ // Deliberately blank rather than an empty-state panel — chips are
+ // decorative chrome describing a dashboard's scope, not data the model
+ // attempted to show; an empty panel here would be noise, not a useful
+ // error signal.
+ if (!props.hasData) return const SizedBox.shrink();
+
+ return Wrap(
+ spacing: theme.spacing / 2,
+ runSpacing: theme.spacing / 2,
+ children: [
+ for (final option in props.options)
+ _Chip(
+ label: option,
+ active: option == props.activeOption,
+ theme: theme,
+ ),
+ ],
+ );
+ }
+}
+
+class _Chip extends StatelessWidget {
+ const _Chip({
+ required this.label,
+ required this.active,
+ required this.theme,
+ });
+
+ final String label;
+ final bool active;
+ final A2UiTheme theme;
+
+ @override
+ Widget build(BuildContext context) => Container(
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+ decoration: BoxDecoration(
+ color: active
+ ? theme.accent.withValues(alpha: 0.18)
+ : theme.border,
+ borderRadius: BorderRadius.circular(theme.pillRadius),
+ border: Border.all(
+ color: active
+ ? theme.accent.withValues(alpha: 0.45)
+ : theme.border,
+ ),
+ ),
+ child: Text(
+ label,
+ style: TextStyle(
+ color: active ? theme.accent : theme.textSoft,
+ fontSize: 12,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ );
+}
diff --git a/workout-logger/lib/genui/src/components/grid_container.dart b/workout-logger/lib/genui/src/components/grid_container.dart
new file mode 100644
index 0000000..407e3d7
--- /dev/null
+++ b/workout-logger/lib/genui/src/components/grid_container.dart
@@ -0,0 +1,118 @@
+import 'package:flutter/material.dart';
+
+import '../a2ui_node.dart';
+import '../a2ui_renderer.dart';
+import '../a2ui_spec.dart';
+import '../a2ui_theme.dart';
+
+@immutable
+class GridContainerProps {
+ const GridContainerProps({required this.columns, required this.children});
+
+ /// Always 1 or 2.
+ final int columns;
+ final List children;
+}
+
+/// Vertical stack or two-column grid of other components.
+///
+/// Children are already parsed by [A2UiParser]; this spec only lays them out,
+/// and recursion runs through the public [A2UiRenderer] so the injected theme
+/// keeps flowing down the tree.
+class GridContainerSpec extends A2UiSpec {
+ const GridContainerSpec();
+
+ /// Below this width a two-column grid squeezes charts unreadably.
+ static const double _collapseWidth = 420;
+
+ @override
+ String get name => 'GridContainer';
+
+ @override
+ List get aliases => const ['Grid', 'Dashboard', 'Container', 'Layout'];
+
+ @override
+ A2UiDoc get doc => const A2UiDoc(
+ schema: 'GridContainer {columns: 1|2, children: [component, ...]}',
+ purpose:
+ 'The wrapper for a multi-part dashboard. Use columns:2 for compact '
+ 'StatCards and columns:1 when it contains charts.',
+ example: {
+ 'component': 'GridContainer',
+ 'props': {
+ 'columns': 2,
+ 'children': [
+ {
+ 'component': 'StatCard',
+ 'props': {'title': 'Sessions', 'value': 14, 'trend': 'up'},
+ },
+ {
+ 'component': 'StatCard',
+ 'props': {'title': 'Volume', 'value': 128000, 'unit': 'kg'},
+ },
+ ],
+ },
+ },
+ );
+
+ @override
+ GridContainerProps parseProps(A2UiNode node) => GridContainerProps(
+ columns: node.props.integer('columns', or: 1).clamp(1, 2),
+ children: node.children,
+ );
+
+ @override
+ Widget buildWidget(
+ BuildContext context,
+ GridContainerProps props,
+ A2UiTheme theme,
+ ) {
+ final children = props.children;
+ if (children.isEmpty) return const SizedBox.shrink();
+
+ return LayoutBuilder(
+ builder: (context, constraints) {
+ final columns =
+ constraints.maxWidth < _collapseWidth ? 1 : props.columns;
+
+ if (columns == 1) {
+ return Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ for (var i = 0; i < children.length; i++) ...[
+ A2UiRenderer(node: children[i]),
+ if (i < children.length - 1)
+ SizedBox(height: theme.spacing / 2),
+ ],
+ ],
+ );
+ }
+
+ final rows = [];
+ for (var i = 0; i < children.length; i += 2) {
+ final right = i + 1 < children.length ? children[i + 1] : null;
+ rows.add(
+ IntrinsicHeight(
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Expanded(child: A2UiRenderer(node: children[i])),
+ SizedBox(width: theme.spacing / 2),
+ Expanded(
+ child: right == null
+ ? const SizedBox.shrink()
+ : A2UiRenderer(node: right),
+ ),
+ ],
+ ),
+ ),
+ );
+ if (i + 2 < children.length) {
+ rows.add(SizedBox(height: theme.spacing / 2));
+ }
+ }
+ return Column(mainAxisSize: MainAxisSize.min, children: rows);
+ },
+ );
+ }
+}
diff --git a/workout-logger/lib/genui/src/components/metric_gauge.dart b/workout-logger/lib/genui/src/components/metric_gauge.dart
new file mode 100644
index 0000000..22be6f0
--- /dev/null
+++ b/workout-logger/lib/genui/src/components/metric_gauge.dart
@@ -0,0 +1,223 @@
+import 'dart:math' as math;
+
+import 'package:flutter/material.dart';
+
+import '../a2ui_node.dart';
+import '../a2ui_panels.dart';
+import '../a2ui_spec.dart';
+import '../a2ui_theme.dart';
+
+@immutable
+class MetricGaugeProps {
+ const MetricGaugeProps({
+ required this.title,
+ required this.value,
+ required this.min,
+ required this.max,
+ required this.unit,
+ this.status,
+ });
+
+ final String title;
+
+ /// Null when the model supplied nothing parseable — the renderer shows an
+ /// empty panel rather than drawing an arc from a bogus number.
+ final double? value;
+ final double min;
+ final double max;
+ final String unit;
+ final String? status;
+
+ /// Fill fraction in `[0, 1]`. Returns 0 for a degenerate range so a NaN
+ /// sweep angle can never reach the canvas.
+ double get progress {
+ final v = value;
+ if (v == null) return 0;
+ final span = max - min;
+ if (span <= 0) return 0;
+ final raw = (v - min) / span;
+ if (raw.isNaN || raw.isInfinite) return 0;
+ return raw.clamp(0.0, 1.0);
+ }
+}
+
+/// A radial gauge for a bounded score such as readiness or recovery.
+class MetricGaugeSpec extends A2UiSpec {
+ const MetricGaugeSpec();
+
+ @override
+ String get name => 'MetricGauge';
+
+ @override
+ List get aliases => const ['Gauge', 'Dial', 'ScoreGauge'];
+
+ @override
+ A2UiDoc get doc => const A2UiDoc(
+ schema:
+ 'MetricGauge {title, value: number, min?, max?, unit?, status?}',
+ purpose:
+ 'A bounded score shown as a dial. Use when the number has a natural '
+ 'floor and ceiling.',
+ example: {
+ 'component': 'MetricGauge',
+ 'props': {
+ 'title': 'Readiness',
+ 'value': 82,
+ 'min': 0,
+ 'max': 100,
+ 'unit': 'pts',
+ 'status': 'Optimal',
+ },
+ },
+ );
+
+ @override
+ MetricGaugeProps parseProps(A2UiNode node) {
+ final p = node.props;
+ final status = p.textOrNull('status');
+ return MetricGaugeProps(
+ title: p.text('title', or: 'Metric'),
+ value: p.numberOrNull('value'),
+ min: p.number('min', or: 0),
+ max: p.number('max', or: 100),
+ unit: p.text('unit'),
+ status: (status == null || status.isEmpty) ? null : status,
+ );
+ }
+
+ @override
+ Widget buildWidget(
+ BuildContext context,
+ MetricGaugeProps props,
+ A2UiTheme theme,
+ ) {
+ final value = props.value;
+ if (value == null) {
+ return A2UiEmptyPanel(
+ message: '${props.title}: No value available',
+ theme: theme,
+ );
+ }
+
+ final display =
+ value % 1 == 0 ? value.toInt().toString() : value.toStringAsFixed(1);
+
+ return A2UiPanel(
+ theme: theme,
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ props.title,
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ color: theme.textPrimary,
+ fontSize: 14,
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ SizedBox(height: theme.spacing),
+ SizedBox(
+ height: 120,
+ width: 120,
+ child: CustomPaint(
+ painter: _GaugeArcPainter(
+ progress: props.progress,
+ track: theme.border,
+ from: theme.accent,
+ to: theme.seriesColor(1),
+ ),
+ child: Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ display,
+ style: TextStyle(
+ color: theme.textPrimary,
+ fontSize: 24,
+ fontWeight: FontWeight.w800,
+ ),
+ ),
+ if (props.unit.isNotEmpty)
+ Text(
+ props.unit,
+ style: TextStyle(color: theme.textMuted, fontSize: 11),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ if (props.status case final String status) ...[
+ SizedBox(height: theme.spacing / 2),
+ Container(
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3),
+ decoration: BoxDecoration(
+ color: theme.accent.withValues(alpha: 0.12),
+ borderRadius: BorderRadius.circular(theme.pillRadius),
+ border: Border.all(color: theme.accent.withValues(alpha: 0.3)),
+ ),
+ child: Text(
+ status,
+ style: TextStyle(
+ color: theme.accent,
+ fontSize: 11,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ),
+ ],
+ ],
+ ),
+ );
+ }
+}
+
+class _GaugeArcPainter extends CustomPainter {
+ const _GaugeArcPainter({
+ required this.progress,
+ required this.track,
+ required this.from,
+ required this.to,
+ });
+
+ final double progress;
+ final Color track;
+ final Color from;
+ final Color to;
+
+ static const double _startAngle = math.pi * 0.75;
+ static const double _sweepAngle = math.pi * 1.5;
+
+ @override
+ void paint(Canvas canvas, Size size) {
+ final center = Offset(size.width / 2, size.height / 2);
+ final radius = math.min(size.width, size.height) / 2 - 8;
+ if (radius <= 0) return;
+ final rect = Rect.fromCircle(center: center, radius: radius);
+
+ final bg = Paint()
+ ..color = track
+ ..style = PaintingStyle.stroke
+ ..strokeWidth = 10
+ ..strokeCap = StrokeCap.round;
+
+ final fg = Paint()
+ ..shader = LinearGradient(colors: [from, to]).createShader(rect)
+ ..style = PaintingStyle.stroke
+ ..strokeWidth = 10
+ ..strokeCap = StrokeCap.round;
+
+ canvas.drawArc(rect, _startAngle, _sweepAngle, false, bg);
+ canvas.drawArc(rect, _startAngle, _sweepAngle * progress, false, fg);
+ }
+
+ @override
+ bool shouldRepaint(_GaugeArcPainter oldDelegate) =>
+ oldDelegate.progress != progress ||
+ oldDelegate.track != track ||
+ oldDelegate.from != from ||
+ oldDelegate.to != to;
+}
diff --git a/workout-logger/lib/genui/src/components/radar_chart.dart b/workout-logger/lib/genui/src/components/radar_chart.dart
new file mode 100644
index 0000000..2ec2725
--- /dev/null
+++ b/workout-logger/lib/genui/src/components/radar_chart.dart
@@ -0,0 +1,152 @@
+import 'package:fl_chart/fl_chart.dart';
+import 'package:flutter/material.dart';
+
+import '../a2ui_node.dart';
+import '../a2ui_panels.dart';
+import '../a2ui_series.dart';
+import '../a2ui_spec.dart';
+import '../a2ui_theme.dart';
+
+@immutable
+class RadarChartProps {
+ const RadarChartProps({
+ required this.title,
+ required this.labels,
+ required this.series,
+ });
+
+ final String title;
+ final List labels;
+
+ /// Every series is exactly [labels].length long — fl_chart requires a uniform
+ /// entry count across datasets, so normalization happens at parse time.
+ final List series;
+
+ /// fl_chart's radar needs at least three axes to form a polygon.
+ bool get hasData => labels.length >= 3 && series.isNotEmpty;
+}
+
+/// Multi-axis balance view over the shared `{labels, series}` shape.
+class RadarChartSpec extends A2UiSpec {
+ const RadarChartSpec();
+
+ @override
+ String get name => 'RadarChart';
+
+ @override
+ List get aliases => const ['Radar', 'SpiderChart', 'BalanceChart'];
+
+ @override
+ A2UiDoc get doc => const A2UiDoc(
+ schema: 'RadarChart {title, labels: [string], '
+ 'series: [{name, values: [number]}]}',
+ purpose:
+ 'Balance across 3+ comparable axes. Use for holistic summaries '
+ 'where every axis shares a scale.',
+ example: {
+ 'component': 'RadarChart',
+ 'props': {
+ 'title': 'Recovery Balance',
+ 'labels': ['Readiness', 'Sleep', 'Volume', 'Intensity'],
+ 'series': [
+ {'name': 'This week', 'values': [85, 90, 75, 80]},
+ {'name': 'Baseline', 'values': [70, 70, 70, 70]},
+ ],
+ },
+ },
+ );
+
+ @override
+ RadarChartProps parseProps(A2UiNode node) {
+ final p = node.props;
+ final labels = p.stringList('labels');
+ final raw = A2UiSeries.extract(p);
+
+ // fl_chart throws when datasets disagree on entry count, so pad or truncate
+ // every series to the axis count before it can reach the widget.
+ final normalized = [
+ for (final s in raw)
+ A2UiSeries(
+ name: s.name,
+ values: [
+ for (var i = 0; i < labels.length; i++)
+ i < s.values.length ? s.values[i] : 0.0,
+ ],
+ ),
+ ];
+
+ return RadarChartProps(
+ title: p.text('title', or: 'Radar Chart'),
+ labels: labels,
+ series: normalized,
+ );
+ }
+
+ @override
+ Widget buildWidget(
+ BuildContext context,
+ RadarChartProps props,
+ A2UiTheme theme,
+ ) {
+ if (!props.hasData) {
+ return A2UiEmptyPanel(
+ message: '${props.title}: No radar data available',
+ theme: theme,
+ );
+ }
+
+ return A2UiPanel(
+ theme: theme,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ A2UiPanelTitle(title: props.title, theme: theme),
+ if (props.series.length > 1) ...[
+ const SizedBox(height: 6),
+ A2UiLegend(
+ names: [for (final s in props.series) s.name],
+ theme: theme,
+ dots: true,
+ ),
+ ],
+ SizedBox(height: theme.spacing),
+ SizedBox(
+ height: 200,
+ child: RadarChart(
+ RadarChartData(
+ dataSets: [
+ for (var i = 0; i < props.series.length; i++)
+ RadarDataSet(
+ fillColor:
+ theme.seriesColor(i).withValues(alpha: 0.2),
+ borderColor: theme.seriesColor(i),
+ entryRadius: 3,
+ borderWidth: 2,
+ dataEntries: [
+ for (final v in props.series[i].values)
+ RadarEntry(value: v),
+ ],
+ ),
+ ],
+ radarBorderData: BorderSide(color: theme.border),
+ gridBorderData: BorderSide(color: theme.border, width: 0.8),
+ tickBorderData: const BorderSide(color: Color(0x00000000)),
+ ticksTextStyle: const TextStyle(color: Color(0x00000000)),
+ getTitle: (index, angle) => RadarChartTitle(
+ text: index < props.labels.length ? props.labels[index] : '',
+ positionPercentageOffset: 0.1,
+ ),
+ titleTextStyle: TextStyle(
+ color: theme.textMuted,
+ fontSize: 11,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/workout-logger/lib/genui/src/components/scatter_plot.dart b/workout-logger/lib/genui/src/components/scatter_plot.dart
new file mode 100644
index 0000000..e062b07
--- /dev/null
+++ b/workout-logger/lib/genui/src/components/scatter_plot.dart
@@ -0,0 +1,232 @@
+import 'package:fl_chart/fl_chart.dart';
+import 'package:flutter/material.dart';
+
+import '../a2ui_node.dart';
+import '../a2ui_panels.dart';
+import '../a2ui_spec.dart';
+import '../a2ui_theme.dart';
+
+@immutable
+class A2UiPoint {
+ const A2UiPoint(this.x, this.y);
+ final double x;
+ final double y;
+}
+
+@immutable
+class ScatterPlotProps {
+ const ScatterPlotProps({
+ required this.title,
+ required this.xLabel,
+ required this.yLabel,
+ required this.points,
+ this.correlation,
+ });
+
+ final String title;
+ final String xLabel;
+ final String yLabel;
+ final List points;
+ final double? correlation;
+
+ bool get hasData => points.isNotEmpty;
+
+ /// Axis bounds with a 10% margin, widened to ±1 when every point shares a
+ /// coordinate so fl_chart never receives a zero-span axis.
+ ({double minX, double maxX, double minY, double maxY}) get bounds {
+ if (points.isEmpty) {
+ return (minX: 0, maxX: 10, minY: 0, maxY: 10);
+ }
+ var minX = points.first.x, maxX = points.first.x;
+ var minY = points.first.y, maxY = points.first.y;
+ for (final p in points) {
+ if (p.x < minX) minX = p.x;
+ if (p.x > maxX) maxX = p.x;
+ if (p.y < minY) minY = p.y;
+ if (p.y > maxY) maxY = p.y;
+ }
+ final xMargin = (maxX - minX) * 0.1;
+ final yMargin = (maxY - minY) * 0.1;
+ return (
+ minX: (minX - (xMargin == 0 ? 1 : xMargin)).floorToDouble(),
+ maxX: (maxX + (xMargin == 0 ? 1 : xMargin)).ceilToDouble(),
+ minY: (minY - (yMargin == 0 ? 1 : yMargin)).floorToDouble(),
+ maxY: (maxY + (yMargin == 0 ? 1 : yMargin)).ceilToDouble(),
+ );
+ }
+}
+
+/// Paired x/y observations with an optional correlation badge.
+class ScatterPlotSpec extends A2UiSpec