From 60ce1a6d710166df6ae45c67adf0bf9c85213de7 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 9 Nov 2026 10:04:42 +0100 Subject: [PATCH] Icons for formulas, fit tile value, channel name Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WzxdWYndZhdUrRGFEjBazQ --- app/lib/services/notification_backend_io.dart | 3 +- app/lib/widgets/body/activity_card.dart | 32 ++-- app/lib/widgets/body/body_tile_grid.dart | 37 +++-- app/lib/widgets/body/formula_figure.dart | 66 ++++++++ app/pubspec.yaml | 2 +- .../body/formula_and_tile_layout_test.dart | 141 ++++++++++++++++++ 6 files changed, 258 insertions(+), 23 deletions(-) create mode 100644 app/lib/widgets/body/formula_figure.dart create mode 100644 app/test/widgets/body/formula_and_tile_layout_test.dart diff --git a/app/lib/services/notification_backend_io.dart b/app/lib/services/notification_backend_io.dart index 5b78bbb..d2d166c 100644 --- a/app/lib/services/notification_backend_io.dart +++ b/app/lib/services/notification_backend_io.dart @@ -15,7 +15,8 @@ class LocalNotificationsBackend implements NotificationBackend { final FlutterLocalNotificationsPlugin plugin; static const _channelId = 'diet_guard_due_slot'; - static const _channelName = 'Meal reminders'; + // The app's proper name, same as the launcher label (no descriptive text). + static const _channelName = 'DietGuard'; @override Future initialize() async { diff --git a/app/lib/widgets/body/activity_card.dart b/app/lib/widgets/body/activity_card.dart index ebaaab7..61196de 100644 --- a/app/lib/widgets/body/activity_card.dart +++ b/app/lib/widgets/body/activity_card.dart @@ -6,6 +6,7 @@ import 'package:diet_guard_app/ui/cues.dart'; import 'package:diet_guard_app/ui/macro_kind.dart'; import 'package:diet_guard_app/ui/theme.dart'; import 'package:diet_guard_app/widgets/body/body_section.dart'; +import 'package:diet_guard_app/widgets/body/formula_figure.dart'; import 'package:flutter/material.dart'; /// How many of the newest sessions to list. @@ -145,7 +146,7 @@ class ActivityCard extends StatelessWidget { } } -/// A formula's proper name on the left, its kcal figure on the right. +/// A formula's glyph on the left, its kcal figure on the right. class _FormulaRow extends StatelessWidget { const _FormulaRow(this.name, this.value); @@ -157,7 +158,12 @@ class _FormulaRow extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs / 2), child: Row( children: [ - Expanded(child: Text(name)), + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: FormulaGlyph(name), + ), + ), MacroFigure(kind: MacroKind.kcal, value: value, size: 16), ], ), @@ -174,10 +180,6 @@ class _SessionTile extends StatelessWidget { final session = result.session; final km = session.km; final muted = Theme.of(context).colorScheme.onSurfaceVariant; - final kcal = [ - for (final name in activityFormulas) - _formulaKcal(name, result.kcal[name]!, result.fallback.contains(name)), - ].join(' ยท '); return Padding( padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs), child: Column( @@ -205,7 +207,20 @@ class _SessionTile extends StatelessWidget { color: MacroKind.kcal.color, semanticLabel: 'kcal', ), - Expanded(child: Text(' $kcal')), + const SizedBox(width: AppSpacing.xs), + Expanded( + child: Wrap( + spacing: AppSpacing.sm, + children: [ + for (final name in activityFormulas) + FormulaFigure( + name, + whole(result.kcal[name]!), + fallback: result.fallback.contains(name), + ), + ], + ), + ), ], ), ], @@ -213,6 +228,3 @@ class _SessionTile extends StatelessWidget { ); } } - -String _formulaKcal(String name, double kcal, bool fallback) => - '$name ${whole(kcal)}${fallback ? '~' : ''}'; diff --git a/app/lib/widgets/body/body_tile_grid.dart b/app/lib/widgets/body/body_tile_grid.dart index 6452af0..ea8554d 100644 --- a/app/lib/widgets/body/body_tile_grid.dart +++ b/app/lib/widgets/body/body_tile_grid.dart @@ -109,20 +109,35 @@ class _Tile extends StatelessWidget { color: theme.colorScheme.onSurfaceVariant, ), ), - const Spacer(), - if (valueIcon != null) - Icon(valueIcon, size: 18, color: theme.colorScheme.primary), - if (value != null) - Flexible( - child: Text( - value, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleMedium?.copyWith( - color: theme.colorScheme.primary, + // The whole space beside the glyph goes to the value; it + // scales down instead of ellipsizing ("70.0 kg" must show). + Expanded( + child: Align( + alignment: Alignment.centerRight, + child: FittedBox( + fit: BoxFit.scaleDown, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (valueIcon != null) + Icon( + valueIcon, + size: 18, + color: theme.colorScheme.primary, + ), + if (value != null) + Text( + value, + maxLines: 1, + style: theme.textTheme.titleMedium?.copyWith( + color: theme.colorScheme.primary, + ), + ), + ], ), ), ), + ), ], ), ), diff --git a/app/lib/widgets/body/formula_figure.dart b/app/lib/widgets/body/formula_figure.dart new file mode 100644 index 0000000..1304814 --- /dev/null +++ b/app/lib/widgets/body/formula_figure.dart @@ -0,0 +1,66 @@ +/// A kcal formula shown as a glyph + number; its proper name is only a +/// long-press tooltip. +library; + +import 'package:diet_guard_app/services/activity_kcal.dart'; +import 'package:diet_guard_app/ui/theme.dart'; +import 'package:flutter/material.dart'; + +/// One distinct glyph per entry of [activityFormulas]. +const Map formulaIcons = { + 'MET': Icons.speed, + 'ACSM': Icons.functions, + 'Per-km': Icons.straighten, +}; + +/// The glyph for formula [name], tooltipped with the formula's proper name. +class FormulaGlyph extends StatelessWidget { + /// Creates a glyph for [name]. + const FormulaGlyph(this.name, {this.size = 16, super.key}); + + /// The formula's proper name (a key of [formulaIcons]). + final String name; + + /// Glyph size. + final double size; + + @override + Widget build(BuildContext context) => Tooltip( + message: name, + child: Icon( + formulaIcons[name], + size: size, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ); +} + +/// Glyph + number (a trailing `~` marks a figure that reuses the MET value). +class FormulaFigure extends StatelessWidget { + /// Creates a figure. + const FormulaFigure( + this.name, + this.value, { + this.fallback = false, + super.key, + }); + + /// The formula's proper name. + final String name; + + /// The formatted kcal number. + final String value; + + /// Whether the figure is a fallback to the MET value. + final bool fallback; + + @override + Widget build(BuildContext context) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + FormulaGlyph(name), + const SizedBox(width: AppSpacing.xs), + Text(fallback ? '$value~' : value), + ], + ); +} diff --git a/app/pubspec.yaml b/app/pubspec.yaml index c29cfc7..ec0da29 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -2,7 +2,7 @@ name: diet_guard_app description: "Companion phone app for diet_guard: log meals on the go." publish_to: 'none' -version: 1.0.144+1 +version: 1.0.145+1 environment: sdk: ^3.12.2 diff --git a/app/test/widgets/body/formula_and_tile_layout_test.dart b/app/test/widgets/body/formula_and_tile_layout_test.dart new file mode 100644 index 0000000..a7aa08b --- /dev/null +++ b/app/test/widgets/body/formula_and_tile_layout_test.dart @@ -0,0 +1,141 @@ +import 'package:diet_guard_app/models/workout_session.dart'; +import 'package:diet_guard_app/widgets/body/activity_card.dart'; +import 'package:diet_guard_app/widgets/body/body_tile_grid.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Future _pumpAt(WidgetTester tester, Size dp, Widget child) async { + tester.view.physicalSize = dp; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: SingleChildScrollView(child: child)), + ), + ); + await tester.pumpAndSettle(); +} + +const _run = Session( + day: '2026-09-27', + kind: 'run', + minutes: 30, + km: 5, + label: 'Morning run', + source: 'pc', +); + +bool _paintsFormulaWord(Widget w) => + w is Text && + const [ + 'MET', + 'ACSM', + 'Per-km', + ].any((word) => (w.data ?? '').contains(word)); + +void main() { + testWidgets('activity formulas are icons; names only in tooltips', ( + tester, + ) async { + await _pumpAt( + tester, + const Size(360, 2400), + const ActivityCard( + sessions: [_run], + weightKg: 70, + exercise: {'MET': 300, 'ACSM': 310, 'Per-km': 320}, + ), + ); + expect(find.byWidgetPredicate(_paintsFormulaWord), findsNothing); + for (final name in ['MET', 'ACSM', 'Per-km']) { + // One in the mean row, one in the session tile. + expect(find.byTooltip(name), findsNWidgets(2)); + } + expect(find.text('300'), findsOneWidget); + expect(find.text('310'), findsOneWidget); + expect(find.text('320'), findsOneWidget); + // Distinct glyphs, so the three are told apart without words. + final icons = tester + .widgetList( + find.descendant( + of: find.byTooltip('MET').first, + matching: find.byType(Icon), + ), + ) + .map((i) => i.icon) + .toSet(); + expect(icons, hasLength(1)); + final glyphs = { + for (final name in ['MET', 'ACSM', 'Per-km']) + (tester + .widget( + find.descendant( + of: find.byTooltip(name).first, + matching: find.byType(Icon), + ), + ) + .icon), + }; + expect(glyphs, hasLength(3)); + }); + + testWidgets('a fallback formula keeps its tilde marker', (tester) async { + const lift = Session( + day: '2026-09-27', + kind: 'lift', + minutes: 60, + km: null, + label: 'Lift', + source: 'pc', + ); + await _pumpAt( + tester, + const Size(360, 2400), + const ActivityCard(sessions: [lift], weightKg: 70, exercise: null), + ); + expect(find.textContaining('~'), findsNWidgets(2)); + expect(find.byWidgetPredicate(_paintsFormulaWord), findsNothing); + }); + + for (final width in [360.0, 411.0]) { + testWidgets('a weight tile value fits whole at $width dp', (tester) async { + await _pumpAt( + tester, + Size(width, 800), + Padding( + padding: const EdgeInsets.all(16), + child: BodyTileGrid( + tiles: [ + BodyTileSpec( + semanticLabel: 'Weight', + icon: Icons.monitor_weight, + value: '70.0 kg', + builder: (_) => const SizedBox.shrink(), + ), + BodyTileSpec( + semanticLabel: 'Body fat', + icon: Icons.percent, + value: '24.5 %', + valueIcon: Icons.cake, + builder: (_) => const SizedBox.shrink(), + ), + ], + ), + ), + ); + for (final value in ['70.0 kg', '24.5 %']) { + final text = find.text(value); + final paragraph = tester.renderObject(text); + expect(paragraph.didExceedMaxLines, isFalse, reason: value); + final card = find.ancestor(of: text, matching: find.byType(Card)); + final inside = tester.getRect(card).inflate(0.5); + expect(inside.contains(tester.getRect(text).topLeft), isTrue); + expect(inside.contains(tester.getRect(text).bottomRight), isTrue); + // The full text is laid out (no ellipsis clipping): its width is + // the natural width of "70.0 kg" at this font. + expect(tester.getSize(text).width, greaterThan(40)); + } + }); + } +}