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 {
+ const ScatterPlotSpec();
+
+ @override
+ String get name => 'ScatterPlot';
+
+ @override
+ List get aliases => const ['Scatter', 'XYPlot', 'Correlation'];
+
+ @override
+ A2UiDoc get doc => const A2UiDoc(
+ schema: 'ScatterPlot {title, xLabel, yLabel, '
+ 'points: [{x: number, y: number}], correlation?: number}',
+ purpose:
+ 'Relationship between two measures. Use when showing whether one '
+ 'metric moves with another.',
+ example: {
+ 'component': 'ScatterPlot',
+ 'props': {
+ 'title': 'Sleep vs Training Volume',
+ 'xLabel': 'Sleep Hours',
+ 'yLabel': 'Volume (kg)',
+ 'correlation': 0.62,
+ 'points': [
+ {'x': 6.2, 'y': 8200},
+ {'x': 7.4, 'y': 11500},
+ {'x': 8.1, 'y': 12900},
+ ],
+ },
+ },
+ );
+
+ @override
+ ScatterPlotProps parseProps(A2UiNode node) {
+ final p = node.props;
+ final points = [];
+ for (final raw in p.objectList('points')) {
+ final x = raw.numberOrNull('x');
+ final y = raw.numberOrNull('y');
+ if (x == null || y == null) continue;
+ points.add(A2UiPoint(x, y));
+ }
+
+ return ScatterPlotProps(
+ title: p.text('title', or: 'Scatter Plot'),
+ xLabel: p.text('xLabel', or: 'X'),
+ yLabel: p.text('yLabel', or: 'Y'),
+ points: points,
+ correlation: p.numberOrNull('correlation'),
+ );
+ }
+
+ @override
+ Widget buildWidget(
+ BuildContext context,
+ ScatterPlotProps props,
+ A2UiTheme theme,
+ ) {
+ if (!props.hasData) {
+ return A2UiEmptyPanel(
+ message: '${props.title}: No paired data available',
+ theme: theme,
+ );
+ }
+
+ final b = props.bounds;
+ final r = props.correlation;
+ final strong = r != null && r.abs() >= 0.5;
+ final badgeColor = strong ? theme.accent : theme.seriesColor(1);
+
+ return A2UiPanel(
+ theme: theme,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Expanded(
+ child: A2UiPanelTitle(title: props.title, theme: theme),
+ ),
+ if (r != null)
+ Container(
+ padding:
+ const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
+ decoration: BoxDecoration(
+ color: badgeColor.withValues(alpha: 0.15),
+ borderRadius: BorderRadius.circular(6),
+ border:
+ Border.all(color: badgeColor.withValues(alpha: 0.4)),
+ ),
+ child: Text(
+ 'r = ${r >= 0 ? '+' : ''}${r.toStringAsFixed(2)}',
+ style: TextStyle(
+ color: badgeColor,
+ fontSize: 11,
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 4),
+ Text(
+ '${props.yLabel} vs. ${props.xLabel}',
+ style: TextStyle(color: theme.textMuted, fontSize: 11),
+ ),
+ SizedBox(height: theme.spacing),
+ SizedBox(
+ height: 195,
+ child: ScatterChart(
+ ScatterChartData(
+ minX: b.minX,
+ maxX: b.maxX,
+ minY: b.minY,
+ maxY: b.maxY,
+ scatterSpots: [
+ for (final p in props.points) ScatterSpot(p.x, p.y),
+ ],
+ gridData: FlGridData(
+ show: true,
+ drawVerticalLine: true,
+ getDrawingHorizontalLine: (_) =>
+ FlLine(color: theme.border, strokeWidth: 1),
+ getDrawingVerticalLine: (_) =>
+ FlLine(color: theme.border, strokeWidth: 1),
+ ),
+ borderData: FlBorderData(show: false),
+ titlesData: FlTitlesData(
+ topTitles: const AxisTitles(
+ sideTitles: SideTitles(showTitles: false)),
+ rightTitles: const AxisTitles(
+ sideTitles: SideTitles(showTitles: false)),
+ bottomTitles: AxisTitles(
+ axisNameWidget: Text(
+ props.xLabel,
+ style: TextStyle(color: theme.textFaint, fontSize: 10),
+ ),
+ sideTitles: SideTitles(
+ showTitles: true,
+ reservedSize: 22,
+ getTitlesWidget: (v, meta) => Text(
+ v.round().toString(),
+ style:
+ TextStyle(color: theme.textFaint, fontSize: 10),
+ ),
+ ),
+ ),
+ leftTitles: AxisTitles(
+ axisNameWidget: Text(
+ props.yLabel,
+ style: TextStyle(color: theme.textFaint, fontSize: 10),
+ ),
+ sideTitles: SideTitles(
+ showTitles: true,
+ reservedSize: 30,
+ getTitlesWidget: (v, meta) => Text(
+ v.round().toString(),
+ style:
+ TextStyle(color: theme.textFaint, fontSize: 10),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/workout-logger/lib/genui/src/components/stat_card.dart b/workout-logger/lib/genui/src/components/stat_card.dart
new file mode 100644
index 0000000..e1f2e1a
--- /dev/null
+++ b/workout-logger/lib/genui/src/components/stat_card.dart
@@ -0,0 +1,172 @@
+import 'package:flutter/material.dart';
+
+import '../a2ui_node.dart';
+import '../a2ui_panels.dart';
+import '../a2ui_spec.dart';
+import '../a2ui_theme.dart';
+
+/// Direction badge shown on a [StatCardSpec].
+enum A2UiTrend {
+ up,
+ down,
+ neutral;
+
+ /// Accepts the canonical words plus the synonyms models reach for, so
+ /// `improving` and `declining` do not silently render as neutral.
+ static A2UiTrend parse(String? raw) {
+ switch (raw?.toLowerCase().trim()) {
+ case 'up':
+ case 'improving':
+ case 'positive':
+ case 'rising':
+ case 'increasing':
+ case 'better':
+ return A2UiTrend.up;
+ case 'down':
+ case 'declining':
+ case 'decline':
+ case 'negative':
+ case 'falling':
+ case 'decreasing':
+ case 'worse':
+ return A2UiTrend.down;
+ default:
+ return A2UiTrend.neutral;
+ }
+ }
+}
+
+@immutable
+class StatCardProps {
+ const StatCardProps({
+ required this.title,
+ required this.value,
+ required this.trend,
+ this.subtitle,
+ });
+
+ final String title;
+ final String value;
+ final String? subtitle;
+ final A2UiTrend trend;
+}
+
+/// A single headline number with an optional caption and direction badge.
+class StatCardSpec extends A2UiSpec {
+ const StatCardSpec();
+
+ @override
+ String get name => 'StatCard';
+
+ @override
+ List get aliases => const ['Stat', 'KpiCard', 'Kpi', 'MetricCard'];
+
+ @override
+ A2UiDoc get doc => const A2UiDoc(
+ schema:
+ 'StatCard {title, value, unit?, subtitle?, trend?: up|down|neutral}',
+ purpose: 'One headline number. Use for totals, averages and deltas.',
+ example: {
+ 'component': 'StatCard',
+ 'props': {
+ 'title': 'Weekly Volume',
+ 'value': 12400,
+ 'unit': 'kg',
+ 'subtitle': 'Last 7 days',
+ 'trend': 'up',
+ },
+ },
+ );
+
+ @override
+ StatCardProps parseProps(A2UiNode node) {
+ final p = node.props;
+
+ final rawValue = p.textOrNull('value');
+ final unit = p.textOrNull('unit');
+ final String value;
+ if (rawValue == null) {
+ value = '—';
+ } else if (unit == null ||
+ unit.isEmpty ||
+ rawValue.trimRight().endsWith(unit)) {
+ // Only a trailing-suffix match counts as "already present" — a naive
+ // substring check would false-positive on e.g. value "10 reps" with
+ // unit "s" (a substring of "reps"), silently dropping a real unit.
+ value = rawValue;
+ } else {
+ value = '$rawValue $unit';
+ }
+
+ final subtitle = p.textOrNull('subtitle');
+
+ return StatCardProps(
+ title: p.text('title', or: 'Metric'),
+ value: value,
+ subtitle: (subtitle == null || subtitle.isEmpty) ? null : subtitle,
+ trend: A2UiTrend.parse(p.textOrNull('trend')),
+ );
+ }
+
+ @override
+ Widget buildWidget(
+ BuildContext context,
+ StatCardProps props,
+ A2UiTheme theme,
+ ) {
+ final (icon, color) = switch (props.trend) {
+ A2UiTrend.up => (Icons.trending_up_rounded, theme.positive),
+ A2UiTrend.down => (Icons.trending_down_rounded, theme.negative),
+ A2UiTrend.neutral => (Icons.trending_flat_rounded, theme.textMuted),
+ };
+
+ return A2UiPanel(
+ theme: theme,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Row(
+ children: [
+ Expanded(
+ child: Text(
+ props.title,
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ color: theme.textMuted,
+ fontSize: 11,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ),
+ Icon(icon, color: color, size: 18),
+ ],
+ ),
+ SizedBox(height: theme.spacing / 2),
+ FittedBox(
+ alignment: Alignment.centerLeft,
+ fit: BoxFit.scaleDown,
+ child: Text(
+ props.value,
+ style: TextStyle(
+ color: theme.textPrimary,
+ fontSize: 22,
+ fontWeight: FontWeight.w800,
+ ),
+ ),
+ ),
+ if (props.subtitle case final String subtitle) ...[
+ const SizedBox(height: 2),
+ Text(
+ subtitle,
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(color: theme.textFaint, fontSize: 11),
+ ),
+ ],
+ ],
+ ),
+ );
+ }
+}
diff --git a/workout-logger/lib/genui/src/default_registry.dart b/workout-logger/lib/genui/src/default_registry.dart
new file mode 100644
index 0000000..5283428
--- /dev/null
+++ b/workout-logger/lib/genui/src/default_registry.dart
@@ -0,0 +1,25 @@
+import 'a2ui_registry.dart';
+import 'components/data_list_group.dart';
+import 'components/dynamic_chart.dart';
+import 'components/filter_chips.dart';
+import 'components/grid_container.dart';
+import 'components/metric_gauge.dart';
+import 'components/radar_chart.dart';
+import 'components/scatter_plot.dart';
+import 'components/stat_card.dart';
+
+/// The standard A2UI vocabulary.
+///
+/// Registration order is the order components appear in the generated prompt,
+/// so the most commonly useful ones come first. Adding a component here adds it
+/// to the parser, the renderer and the model's instructions at once.
+final A2UiRegistry defaultA2UiRegistry = A2UiRegistry(const [
+ GridContainerSpec(),
+ StatCardSpec(),
+ DynamicChartSpec(),
+ DataListGroupSpec(),
+ MetricGaugeSpec(),
+ ScatterPlotSpec(),
+ RadarChartSpec(),
+ FilterChipsSpec(),
+]);
diff --git a/workout-logger/lib/main.dart b/workout-logger/lib/main.dart
index 8d512d6..0991b49 100644
--- a/workout-logger/lib/main.dart
+++ b/workout-logger/lib/main.dart
@@ -27,6 +27,8 @@ import 'services/managers/readiness_manager.dart';
import 'services/managers/health_history_manager.dart';
import 'services/managers/conversation_manager.dart';
import 'theme/app_theme.dart';
+import 'genui/a2ui.dart';
+import 'theme/a2ui_app_theme.dart';
import 'screens/home_screen.dart';
import 'screens/onboarding_screen.dart';
@@ -134,16 +136,20 @@ class WorkoutLoggerApp extends StatelessWidget {
// CoachToolService backs AI tool calls; reads from WorkoutProvider + PRManager.
Provider(
create: (ctx) => CoachToolService(
- ctx.read(),
- ctx.read(),
+ workoutProvider: ctx.read(),
+ prManager: ctx.read(),
+ healthHistory: ctx.read(),
),
),
],
- child: MaterialApp(
- title: 'Workout Logger',
- debugShowCheckedModeBanner: false,
- theme: AppTheme.darkTheme,
- home: const AppInitializer(),
+ child: A2UiThemeProvider(
+ theme: repforgeA2UiTheme,
+ child: MaterialApp(
+ title: 'Workout Logger',
+ debugShowCheckedModeBanner: false,
+ theme: AppTheme.darkTheme,
+ home: const AppInitializer(),
+ ),
),
);
}
diff --git a/workout-logger/lib/models/models.dart b/workout-logger/lib/models/models.dart
index 493013a..2fb5ec6 100644
--- a/workout-logger/lib/models/models.dart
+++ b/workout-logger/lib/models/models.dart
@@ -70,6 +70,7 @@ class Exercise {
final List muscleActivations;
final String category; // 'compound' or 'isolation'
final bool isCustom; // User-created exercise
+ final List? availableHandles; // Attachment/handle options e.g. ['Rope', 'Bar']
Exercise({
required this.id,
@@ -77,6 +78,7 @@ class Exercise {
required this.muscleActivations,
required this.category,
this.isCustom = false,
+ this.availableHandles,
});
String get primaryMuscle {
@@ -93,6 +95,7 @@ class Exercise {
'muscleActivations': muscleActivations.map((m) => m.toJson()).toList(),
'category': category,
'isCustom': isCustom,
+ 'availableHandles': availableHandles,
};
factory Exercise.fromJson(Map json) => Exercise(
@@ -103,6 +106,7 @@ class Exercise {
.toList(),
category: json['category'],
isCustom: json['isCustom'] ?? false,
+ availableHandles: (json['availableHandles'] as List?)?.cast(),
);
}
@@ -115,6 +119,10 @@ class WorkoutSet {
final List? drops; // For dropsets
final int? timeTaken; // seconds
final DateTime timestamp;
+ final double? assistWeight;
+ final double? extraWeight;
+ final String? handle;
+ final double? bodyWeightAtLog;
WorkoutSet({
required this.weight,
@@ -123,18 +131,47 @@ class WorkoutSet {
this.drops,
this.timeTaken,
DateTime? timestamp,
+ this.assistWeight,
+ this.extraWeight,
+ this.handle,
+ this.bodyWeightAtLog,
}) : timestamp = timestamp ?? DateTime.now();
- double get volume {
- double vol = weight * reps;
+ /// Per-rep effective load for the main (non-drop) entry of this set: for
+ /// assisted-bodyweight sets (i.e. [assistWeight] is set) this is
+ /// `bodyweight − assist + extra`, snapshotted against [bodyWeightAtLog]
+ /// (falling back to 70.0) so historical values stay correct even if the
+ /// user's current bodyweight later changes. Conventional (non-assisted)
+ /// sets just use [weight]. Use this (not raw [weight]) wherever a
+ /// "how heavy was this set" comparison needs to be consistent with
+ /// [calculateVolume] for assisted-bodyweight exercises.
+ double get effectiveWeight {
+ final assist = assistWeight;
+ if (assist == null) return weight;
+ final bw = bodyWeightAtLog ?? 70.0;
+ return max(0.0, bw - assist + (extraWeight ?? 0.0));
+ }
+
+ double calculateVolume({double? userBodyWeight, bool? isAssistedBW}) {
+ final assisted = isAssistedBW ?? (assistWeight != null);
+ final bw = bodyWeightAtLog ?? userBodyWeight ?? 70.0;
+ final effW = assisted
+ ? max(0.0, bw - (assistWeight ?? weight) + (extraWeight ?? 0.0))
+ : weight;
+ double vol = effW * reps;
if (isDropset && drops != null) {
- for (var drop in drops!) {
- vol += drop.weight * drop.reps;
+ for (final drop in drops!) {
+ final dropEff = assisted
+ ? max(0.0, bw - drop.weight + (extraWeight ?? 0.0))
+ : drop.weight;
+ vol += dropEff * drop.reps;
}
}
return vol;
}
+ double get volume => calculateVolume();
+
Map toJson() => {
'weight': weight,
'reps': reps,
@@ -142,6 +179,10 @@ class WorkoutSet {
'drops': drops?.map((d) => d.toJson()).toList(),
'timeTaken': timeTaken,
'timestamp': timestamp.toIso8601String(),
+ 'assistWeight': assistWeight,
+ 'extraWeight': extraWeight,
+ 'handle': handle,
+ 'bodyWeightAtLog': bodyWeightAtLog,
};
factory WorkoutSet.fromJson(Map json) => WorkoutSet(
@@ -153,6 +194,10 @@ class WorkoutSet {
: null,
timeTaken: json['timeTaken'],
timestamp: DateTime.parse(json['timestamp']),
+ assistWeight: (json['assistWeight'] as num?)?.toDouble(),
+ extraWeight: (json['extraWeight'] as num?)?.toDouble(),
+ handle: json['handle'] as String?,
+ bodyWeightAtLog: (json['bodyWeightAtLog'] as num?)?.toDouble(),
);
WorkoutSet copyWith({
@@ -162,6 +207,10 @@ class WorkoutSet {
Object? drops = _sentinel,
Object? timeTaken = _sentinel,
Object? timestamp = _sentinel,
+ Object? assistWeight = _sentinel,
+ Object? extraWeight = _sentinel,
+ Object? handle = _sentinel,
+ Object? bodyWeightAtLog = _sentinel,
}) => WorkoutSet(
weight: weight == _sentinel ? this.weight : weight as double,
reps: reps == _sentinel ? this.reps : reps as int,
@@ -169,6 +218,10 @@ class WorkoutSet {
drops: drops == _sentinel ? this.drops : drops as List?,
timeTaken: timeTaken == _sentinel ? this.timeTaken : timeTaken as int?,
timestamp: timestamp == _sentinel ? this.timestamp : timestamp as DateTime?,
+ assistWeight: assistWeight == _sentinel ? this.assistWeight : assistWeight as double?,
+ extraWeight: extraWeight == _sentinel ? this.extraWeight : extraWeight as double?,
+ handle: handle == _sentinel ? this.handle : handle as String?,
+ bodyWeightAtLog: bodyWeightAtLog == _sentinel ? this.bodyWeightAtLog : bodyWeightAtLog as double?,
);
}
@@ -195,8 +248,17 @@ class ExerciseLog {
final String exerciseId;
final List sets;
final String? notes;
+ final String? handle;
+
+ ExerciseLog({
+ required this.exerciseId,
+ required this.sets,
+ this.notes,
+ this.handle,
+ });
- ExerciseLog({required this.exerciseId, required this.sets, this.notes});
+ double calculateTotalVolume({double? userBodyWeight, bool? isAssistedBW}) =>
+ sets.fold(0.0, (sum, set) => sum + set.calculateVolume(userBodyWeight: userBodyWeight, isAssistedBW: isAssistedBW));
double get totalVolume => sets.fold(0.0, (sum, set) => sum + set.volume);
@@ -204,24 +266,28 @@ class ExerciseLog {
'exerciseId': exerciseId,
'sets': sets.map((s) => s.toJson()).toList(),
'notes': notes,
+ 'handle': handle,
};
factory ExerciseLog.fromJson(Map json) => ExerciseLog(
exerciseId: json['exerciseId'],
sets: (json['sets'] as List).map((s) => WorkoutSet.fromJson(s)).toList(),
notes: json['notes'],
+ handle: json['handle'] as String?,
);
ExerciseLog copyWith({
Object? exerciseId = _sentinel,
Object? sets = _sentinel,
Object? notes = _sentinel,
+ Object? handle = _sentinel,
}) => ExerciseLog(
exerciseId: exerciseId == _sentinel
? this.exerciseId
: exerciseId as String,
sets: sets == _sentinel ? this.sets : sets as List,
notes: notes == _sentinel ? this.notes : notes as String?,
+ handle: handle == _sentinel ? this.handle : handle as String?,
);
}
diff --git a/workout-logger/lib/screens/ai_coach_screen.dart b/workout-logger/lib/screens/ai_coach_screen.dart
index f24758f..a97406f 100644
--- a/workout-logger/lib/screens/ai_coach_screen.dart
+++ b/workout-logger/lib/screens/ai_coach_screen.dart
@@ -10,6 +10,7 @@ import 'package:provider/provider.dart';
import 'package:gpt_markdown/gpt_markdown.dart';
import '../models/models.dart';
+import '../genui/a2ui.dart';
import '../viewmodels/ai_coach_view_model.dart';
import '../services/ai/gemini_ai_service.dart';
import '../services/ai/coach_tool_service.dart';
@@ -745,7 +746,7 @@ class _MessageBubble extends StatelessWidget {
height: 1.55,
),
)
- : _CoachMarkdown(text: message.text),
+ : CoachMessageContent(text: message.text),
),
),
],
@@ -785,7 +786,7 @@ class _StreamingBubble extends StatelessWidget {
),
child: text.isEmpty
? const RFLoadingDots()
- : _CoachMarkdown(text: text),
+ : CoachMessageContent(text: text, streaming: true),
),
),
],
@@ -794,7 +795,82 @@ class _StreamingBubble extends StatelessWidget {
}
}
-/// Markdown renderer for coach replies, styled to the app theme.
+/// Renders one coach reply: an A2UI dashboard when the text is a UI payload,
+/// otherwise Markdown.
+///
+/// Public so widget tests can drive it directly. Parsing is memoized per text
+/// value — the old code re-parsed on every rebuild, including on every partial
+/// frame of a stream.
+class CoachMessageContent extends StatefulWidget {
+ const CoachMessageContent({
+ super.key,
+ required this.text,
+ this.streaming = false,
+ });
+
+ final String text;
+
+ /// True while tokens are still arriving, so a half-written JSON payload
+ /// shows a placeholder instead of raw braces.
+ final bool streaming;
+
+ @override
+ State createState() => _CoachMessageContentState();
+}
+
+class _CoachMessageContentState extends State {
+ static final _parser = A2UiParser(defaultA2UiRegistry);
+
+ A2UiNode? _node;
+ String? _parsedFrom;
+
+ @override
+ void didUpdateWidget(CoachMessageContent oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ if (oldWidget.text != widget.text) _parsedFrom = null;
+ }
+
+ A2UiNode? get _resolved {
+ if (_parsedFrom != widget.text) {
+ _parsedFrom = widget.text;
+ _node = _parser.parse(widget.text);
+ }
+ return _node;
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final node = _resolved;
+ if (node != null) return A2UiRenderer(node: node);
+
+ // Mid-stream JSON: hide the braces behind a progress row rather than
+ // letting the Markdown renderer spill raw payload into the bubble.
+ if (widget.streaming && _parser.looksLikeUi(widget.text)) {
+ return Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const SizedBox(
+ width: 12,
+ height: 12,
+ child: CircularProgressIndicator(strokeWidth: 2),
+ ),
+ const SizedBox(width: AppSpacing.sm),
+ Text(
+ 'Building dashboard…',
+ style: TextStyle(
+ fontFamily: 'Geist',
+ color: AppColors.textMuted,
+ fontSize: 13,
+ ),
+ ),
+ ],
+ );
+ }
+
+ return _CoachMarkdown(text: widget.text);
+ }
+}
+
class _CoachMarkdown extends StatelessWidget {
const _CoachMarkdown({required this.text});
final String text;
diff --git a/workout-logger/lib/screens/widgets/exercise_input_section.dart b/workout-logger/lib/screens/widgets/exercise_input_section.dart
index 687809d..acc511f 100644
--- a/workout-logger/lib/screens/widgets/exercise_input_section.dart
+++ b/workout-logger/lib/screens/widgets/exercise_input_section.dart
@@ -7,6 +7,19 @@ import '../../services/settings_provider.dart';
import '../../theme/app_theme.dart';
import 'rf_widgets.dart';
+// Exercise IDs treated as bodyweight-assisted (e.g. an assisted-dip/pull-up
+// machine). Computed once here so the load panel and the input row never
+// drift out of sync on which exercises count as "assisted".
+const Set _assistedBodyweightExerciseIds = {
+ 'pull_ups',
+ 'chin_ups',
+ 'dips',
+ 'push_ups',
+};
+
+bool isAssistedBodyweightExercise(String? exerciseId) =>
+ exerciseId != null && _assistedBodyweightExerciseIds.contains(exerciseId);
+
// ── ExerciseInputSection ──────────────────────────────────────────────────────
// Renders: AI suggestion card, weight/reps inputs, dropset section,
// LOG SET button, previous sets, last session info, program metadata banner.
@@ -37,6 +50,9 @@ class ExerciseInputSection extends StatelessWidget {
this.programSlot,
this.programWeek,
this.exerciseId,
+ this.availableHandles,
+ this.selectedHandle,
+ this.onHandleChanged,
});
final double currentWeight;
@@ -63,9 +79,18 @@ class ExerciseInputSection extends StatelessWidget {
final ProgramExerciseSlot? programSlot;
final ProgramWeek? programWeek;
final String? exerciseId;
+ final List? availableHandles;
+ final String? selectedHandle;
+ final ValueChanged? onHandleChanged;
@override
Widget build(BuildContext context) {
+ final isAssistedBW = isAssistedBodyweightExercise(exerciseId);
+ final effectiveWeight = (settings.userBodyWeight - currentWeight).clamp(0.0, 500.0);
+ final effectiveWeightDisplay = settings.toDisplay(effectiveWeight);
+ final bodyWeightDisplay = settings.toDisplay(settings.userBodyWeight);
+ final currentWeightDisplay = settings.toDisplay(currentWeight);
+
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -73,6 +98,20 @@ class ExerciseInputSection extends StatelessWidget {
if (programSlot != null && programWeek != null)
_ProgramMetaBanner(slot: programSlot!, week: programWeek!),
+ // Handle / Attachment Selector
+ if (availableHandles != null && availableHandles!.isNotEmpty) ...[
+ _HandleSelector(
+ availableHandles: availableHandles!,
+ selectedHandle: selectedHandle,
+ onChanged: onHandleChanged,
+ // Once a set has been logged for this exercise instance, the
+ // handle is locked — the selector must not let the user (or
+ // silently appear to) relabel already-recorded sets.
+ locked: previousSets.isNotEmpty,
+ ),
+ const SizedBox(height: AppSpacing.sm),
+ ],
+
// AI suggestion
if (recommendations.isNotEmpty)
_RecommendationCard(
@@ -91,10 +130,31 @@ class ExerciseInputSection extends StatelessWidget {
currentWeight: currentWeight,
currentReps: currentReps,
settings: settings,
- exerciseId: exerciseId,
+ isAssistedBW: isAssistedBW,
onWeightChanged: onWeightChanged,
onRepsChanged: onRepsChanged,
),
+ if (isAssistedBW) ...[
+ const SizedBox(height: 6),
+ Container(
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
+ decoration: BoxDecoration(
+ color: AppColors.primary.withValues(alpha: 0.1),
+ borderRadius: BorderRadius.circular(AppRadius.sm),
+ border: Border.all(color: AppColors.primary.withValues(alpha: 0.2)),
+ ),
+ child: Row(
+ children: [
+ const Icon(Icons.fitness_center_rounded, size: 14, color: AppColors.primary),
+ const SizedBox(width: 6),
+ Text(
+ 'Effective Volume Load: ${effectiveWeightDisplay.toStringAsFixed(1)} ${settings.unitLabel} (${bodyWeightDisplay.toStringAsFixed(1)} BW − ${currentWeightDisplay.toStringAsFixed(1)} Assist) × $currentReps reps',
+ style: const TextStyle(fontSize: 11, color: AppColors.textSoft, fontWeight: FontWeight.w500),
+ ),
+ ],
+ ),
+ ),
+ ],
const SizedBox(height: AppSpacing.md),
],
@@ -139,6 +199,76 @@ class ExerciseInputSection extends StatelessWidget {
}
}
+// ── Handle Selector ──────────────────────────────────────────────────────────
+class _HandleSelector extends StatelessWidget {
+ const _HandleSelector({
+ required this.availableHandles,
+ required this.selectedHandle,
+ required this.onChanged,
+ this.locked = false,
+ });
+
+ final List availableHandles;
+ final String? selectedHandle;
+ final ValueChanged? onChanged;
+ final bool locked;
+
+ @override
+ Widget build(BuildContext context) {
+ // Only show a chip as selected once the user (or a restored draft) has
+ // actually chosen it — never default-highlight the first handle just
+ // because nothing has been persisted yet.
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text(
+ 'ATTACHMENT / HANDLE VARIATION',
+ style: TextStyle(
+ color: AppColors.textMuted,
+ fontSize: 10,
+ fontWeight: FontWeight.w600,
+ letterSpacing: 0.5,
+ ),
+ ),
+ const SizedBox(height: 6),
+ SingleChildScrollView(
+ scrollDirection: Axis.horizontal,
+ child: Row(
+ children: availableHandles.map((handle) {
+ final isSelected = selectedHandle == handle;
+ return Padding(
+ padding: const EdgeInsets.only(right: 6),
+ child: FilterChip(
+ label: Text(handle),
+ selected: isSelected,
+ onSelected: locked
+ ? null
+ : (selected) {
+ if (selected && onChanged != null) {
+ onChanged!(handle);
+ }
+ },
+ selectedColor: AppColors.primary.withValues(alpha: 0.25),
+ backgroundColor: AppColors.surface,
+ checkmarkColor: AppColors.primary,
+ labelStyle: TextStyle(
+ color: isSelected ? AppColors.primary : AppColors.textSoft,
+ fontSize: 12,
+ fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
+ ),
+ side: BorderSide(
+ color: isSelected ? AppColors.primary : AppColors.glassBorder,
+ ),
+ ),
+ );
+ }).toList(),
+ ),
+ ),
+ ],
+ );
+ }
+}
+
// ── Recommendation Card ────────────────────────────────────────────────────────
class _RecommendationCard extends StatelessWidget {
const _RecommendationCard({
@@ -259,7 +389,7 @@ class _InputRow extends StatelessWidget {
required this.settings,
required this.onWeightChanged,
required this.onRepsChanged,
- this.exerciseId,
+ this.isAssistedBW = false,
});
final double currentWeight;
@@ -267,12 +397,10 @@ class _InputRow extends StatelessWidget {
final SettingsProvider settings;
final ValueChanged onWeightChanged;
final ValueChanged onRepsChanged;
- final String? exerciseId;
+ final bool isAssistedBW;
@override
Widget build(BuildContext context) {
- final isAssistedBW =
- exerciseId == 'pull_ups' || exerciseId == 'chin_ups';
final weightLabel =
isAssistedBW ? 'Assist (${settings.unitLabel})' : settings.unitLabel;
final displayWeight = settings.toDisplay(currentWeight);
diff --git a/workout-logger/lib/screens/workout_flow_screen.dart b/workout-logger/lib/screens/workout_flow_screen.dart
index 3377710..56ac25c 100644
--- a/workout-logger/lib/screens/workout_flow_screen.dart
+++ b/workout-logger/lib/screens/workout_flow_screen.dart
@@ -168,7 +168,11 @@ class _WorkoutFlowScreenState extends State {
final exercise = provider.currentExercise;
if (exercise == null) return;
- final last = provider.getLastSessionForExercise(exercise.id);
+ final currentHandle = provider.currentExerciseLog?.handle;
+ final last = provider.getLastSessionForExercise(
+ exercise.id,
+ handle: currentHandle,
+ );
if (last != null && last.sets.isNotEmpty) {
final lastSet = last.sets.last;
setState(() {
@@ -265,12 +269,13 @@ class _WorkoutFlowScreenState extends State {
final isFirst = idx == 0;
final isLast = idx >= totalExercises - 1;
+ final selectedHandle = log?.handle;
final recommendations = exercise != null
- ? provider.getRecommendations(exercise.id)
+ ? provider.getRecommendations(exercise.id, handle: selectedHandle)
: [];
final lastSession = exercise != null
- ? provider.getLastSessionForExercise(exercise.id)
+ ? provider.getLastSessionForExercise(exercise.id, handle: selectedHandle)
: null;
return Column(
@@ -316,6 +321,12 @@ class _WorkoutFlowScreenState extends State {
lastSession: lastSession,
settings: settings,
exerciseId: exercise?.id,
+ availableHandles: exercise?.availableHandles,
+ selectedHandle: selectedHandle,
+ onHandleChanged: (h) {
+ provider.setExerciseHandle(h);
+ _loadLastSessionData();
+ },
programSlot: _slot(idx, p: provider),
programWeek: _resolvedWeek(provider),
onWeightChanged: (v) => setState(() => _currentWeight = v),
@@ -534,15 +545,26 @@ class _WorkoutFlowScreenState extends State {
void _completeSet() {
final provider = context.read();
+ final settings = context.read();
final idx = provider.currentExerciseIndex;
final currentSlot = _slot(idx, p: provider);
final nextSlot = _slot(idx + 1, p: provider);
+ // For bodyweight-assisted exercises (assisted dips/pull-ups/etc.) the
+ // weight input represents the assist load, not the lifted load. Snapshot
+ // the assist weight and the bodyweight it was computed against so
+ // historical volume stays correct even if the user's bodyweight later
+ // changes in settings.
+ final isAssistedBW =
+ isAssistedBodyweightExercise(provider.currentExercise?.id);
+
final set = WorkoutSet(
weight: _currentWeight,
reps: _currentReps,
isDropset: _isDropset,
drops: _isDropset ? List.from(_drops) : null,
+ assistWeight: isAssistedBW ? _currentWeight : null,
+ bodyWeightAtLog: isAssistedBW ? settings.userBodyWeight : null,
);
provider.addSet(set);
diff --git a/workout-logger/lib/services/ai/coach_tool_service.dart b/workout-logger/lib/services/ai/coach_tool_service.dart
index c43c384..b377052 100644
--- a/workout-logger/lib/services/ai/coach_tool_service.dart
+++ b/workout-logger/lib/services/ai/coach_tool_service.dart
@@ -5,11 +5,15 @@
// query methods on WorkoutProvider / PRManager — no new analytics logic lives
// here, only the schema + arg parsing + JSON shaping.
+import 'dart:math' as math;
+
import 'package:google_generative_ai/google_generative_ai.dart';
import '../../models/models.dart';
+import '../../models/sleep_hr_models.dart';
import '../workout_provider.dart';
import '../managers/pr_manager.dart';
+import '../managers/health_history_manager.dart';
class AmbiguousMatchException implements Exception {
const AmbiguousMatchException(this.candidates);
@@ -19,8 +23,15 @@ class AmbiguousMatchException implements Exception {
class CoachToolService {
final WorkoutProvider _wp;
final PRManager _pr;
+ final HealthHistoryManager? _hh;
- CoachToolService(this._wp, this._pr);
+ CoachToolService({
+ required WorkoutProvider workoutProvider,
+ required PRManager prManager,
+ HealthHistoryManager? healthHistory,
+ }) : _wp = workoutProvider,
+ _pr = prManager,
+ _hh = healthHistory;
/// Tool declaration for the optimizer screen's `ask_user_questions` flow.
/// NOT included in the coach's tool list — only the optimizer adds it.
@@ -70,6 +81,30 @@ class CoachToolService {
/// Tool declarations advertised to the model.
List