diff --git a/app/lib/widgets/body/dated_value_card.dart b/app/lib/widgets/body/dated_value_card.dart index 27eaa2b..8085c22 100644 --- a/app/lib/widgets/body/dated_value_card.dart +++ b/app/lib/widgets/body/dated_value_card.dart @@ -167,7 +167,13 @@ class _DatedValueCardState extends State { if (latest == null) Icon(Icons.block, size: 18, color: scheme.outline) else - Text('${_fmt(latest.$2)} ${latest.$1}'), + // Scales down instead of clipping on a narrow sheet. + Flexible( + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text('${_fmt(latest.$2)} ${latest.$1}', maxLines: 1), + ), + ), ], ), Row( diff --git a/app/lib/widgets/body/formula_figure.dart b/app/lib/widgets/body/formula_figure.dart index 1304814..be77b24 100644 --- a/app/lib/widgets/body/formula_figure.dart +++ b/app/lib/widgets/body/formula_figure.dart @@ -1,5 +1,5 @@ -/// A kcal formula shown as a glyph + number; its proper name is only a -/// long-press tooltip. +/// A kcal formula shown as a glyph + number; its proper name is only the +/// glyph's spoken label. library; import 'package:diet_guard_app/services/activity_kcal.dart'; @@ -13,7 +13,7 @@ const Map formulaIcons = { 'Per-km': Icons.straighten, }; -/// The glyph for formula [name], tooltipped with the formula's proper name. +/// The glyph for formula [name], spoken as the formula's proper name. class FormulaGlyph extends StatelessWidget { /// Creates a glyph for [name]. const FormulaGlyph(this.name, {this.size = 16, super.key}); @@ -24,14 +24,13 @@ class FormulaGlyph extends StatelessWidget { /// Glyph size. final double size; + // The proper name is spoken only: a tooltip paints on long-press. @override - Widget build(BuildContext context) => Tooltip( - message: name, - child: Icon( - formulaIcons[name], - size: size, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + Widget build(BuildContext context) => Icon( + formulaIcons[name], + size: size, + color: Theme.of(context).colorScheme.onSurfaceVariant, + semanticLabel: name, ); } diff --git a/app/lib/widgets/body/goal_icons.dart b/app/lib/widgets/body/goal_icons.dart index 4ba2f94..977e9ff 100644 --- a/app/lib/widgets/body/goal_icons.dart +++ b/app/lib/widgets/body/goal_icons.dart @@ -3,6 +3,7 @@ library; import 'package:diet_guard_app/services/body_energy.dart'; +import 'package:diet_guard_app/widgets/body/formula_figure.dart'; import 'package:flutter/material.dart'; /// Lose = down arrow, maintain = level bar, gain = up arrow. @@ -20,24 +21,25 @@ IconData timeUnitIcon(String unit) => switch (unit) { _ => Icons.event_repeat, }; -/// The five levels ramp from a chair to a barbell; measured is a gauge. +/// The five levels ramp from a chair to a barbell; a measured choice wears +/// its formula's glyph (the same one the Activity sheet shows next to kcal). IconData activityIcon(String key) => switch (key) { 'sedentary' => Icons.chair, 'light' => Icons.directions_walk, 'moderate' => Icons.directions_bike, 'very' => Icons.directions_run, 'extra' => Icons.fitness_center, + _ when key.startsWith(measuredPrefix) => + formulaIcons[key.substring(measuredPrefix.length)] ?? Icons.speed, _ => Icons.speed, }; -/// Short non-word caption under an activity icon: the TDEE multiplier of a -/// level, or the proper name of the measured formula. -String activityCaption(String key) { - if (key.startsWith(measuredPrefix)) { - return key.substring(measuredPrefix.length); - } +/// Numeric caption under an activity icon: the TDEE multiplier of a level. +/// Null for a measured formula (its glyph names it; the proper name is only +/// spoken) and for an unknown key. +String? activityCaption(String key) { for (final level in activityLevels) { if (level.key == key) return formatG(level.factor); } - return key; + return null; } diff --git a/app/test/no_words_body_test.dart b/app/test/no_words_body_test.dart new file mode 100644 index 0000000..7d11c05 --- /dev/null +++ b/app/test/no_words_body_test.dart @@ -0,0 +1,90 @@ +// Guard: the Body screen, every tile sheet and the activity-method chooser +// paint no words (only numbers, unit symbols, MET and workout labels). + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'label_finders.dart'; +import 'no_words_support.dart'; +import 'screens/body_screen_support.dart'; + +/// Workout labels the PC published (content, see `seedPerson`), plus the +/// proper names of the BMI / ideal-weight / BMR formulas that label their +/// figures (DOUBT rows in keep.md: kept from the first pilot, outside the +/// activity sheet). Activity formulas (ACSM, Per-km) are NOT here: that sheet +/// and its chooser show glyphs only. +const Set _workouts = { + 'Evening run', + 'StrongLifts A', + 'Harris-Benedict (revised 1984)', + 'Peterson (BMI 22)', + 'Mifflin-St Jeor', + 'Katch-McArdle', + 'Cunningham', + 'BMI Prime', + 'Trefethen', + 'Devine', + 'Robinson', + 'Miller', +}; + +void main() { + late MemoryDocStore store; + + setUp(() async { + store = MemoryDocStore(); + await setUpBodyServices(store); + }); + tearDown(tearDownBodyServices); + + test('the checker flags an injected word (canary)', () { + expect(wordsIn('70.0 kg'), isEmpty); + expect(wordsIn('265 MET'), isEmpty); + expect(wordsIn('Measured (ACSM)'), isNotEmpty); + expect(wordsIn('Evening run 4 km', content: _workouts), ['km']); + expect(wordsIn('Evening run', content: _workouts), isEmpty); + }); + + testWidgets('canary: a painted word fails the guard', (tester) async { + await tester.pumpWidget(const MaterialApp(home: Text('Activity'))); + expect(() => expectNoWords(tester), throwsA(isA())); + }); + + for (final seeded in [false, true]) { + testWidgets('body tiles and sheets paint no words (seeded: $seeded)', ( + tester, + ) async { + if (seeded) await seedPerson(store); + await pumpBody(tester); + final found = wordyStrings( + tester, + content: _workouts, + minStrings: 5, + where: 'tiles', + ); + for (final title in tileTitles(tester)) { + await openTile(tester, title); + found.addAll(wordyStrings(tester, content: _workouts, where: title)); + await closeSheet(tester); + } + expect(found, isEmpty); + }); + } + + testWidgets('the activity-method chooser paints no words', (tester) async { + await seedPerson(store); + await pumpBody(tester); + await openTile(tester, 'Daily calorie target'); + await tester.tap(bySemLabel('Moderate').first); + await tester.pumpAndSettle(); + expectNoWords( + tester, + content: _workouts, + minStrings: 8, + where: 'activity chooser', + ); + await tester.tap(bySemLabel('Measured (ACSM)').last); + await tester.pumpAndSettle(); + expectNoWords(tester, content: _workouts, where: 'measured goal'); + }); +} diff --git a/app/test/widgets/body/formula_and_tile_layout_test.dart b/app/test/widgets/body/formula_and_tile_layout_test.dart index a7aa08b..94145c4 100644 --- a/app/test/widgets/body/formula_and_tile_layout_test.dart +++ b/app/test/widgets/body/formula_and_tile_layout_test.dart @@ -5,6 +5,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../../label_finders.dart'; + Future _pumpAt(WidgetTester tester, Size dp, Widget child) async { tester.view.physicalSize = dp; tester.view.devicePixelRatio = 1; @@ -35,9 +37,7 @@ bool _paintsFormulaWord(Widget w) => ].any((word) => (w.data ?? '').contains(word)); void main() { - testWidgets('activity formulas are icons; names only in tooltips', ( - tester, - ) async { + testWidgets('activity formulas are icons; names only spoken', (tester) async { await _pumpAt( tester, const Size(360, 2400), @@ -48,34 +48,19 @@ void main() { ), ); expect(find.byWidgetPredicate(_paintsFormulaWord), findsNothing); + // A tooltip paints on long-press: none may carry a formula name. + expect(find.byType(Tooltip), 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(byIconLabel(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), + tester.widget(byIconLabel(name).first).icon, }; expect(glyphs, hasLength(3)); }); @@ -98,11 +83,12 @@ void main() { 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 { + // A 360 dp phone, the Pixel's 411 dp, and a 4K web window. + for (final size in const [Size(360, 800), Size(411, 800), Size(3840, 2160)]) { + testWidgets('a weight tile value fits whole at $size', (tester) async { await _pumpAt( tester, - Size(width, 800), + size, Padding( padding: const EdgeInsets.all(16), child: BodyTileGrid( @@ -130,11 +116,14 @@ void main() { 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 painted rect (after FittedBox scaling) sits inside the tile. + final painted = tester.getRect(text); + expect(inside.contains(painted.topLeft), isTrue, reason: value); + expect(inside.contains(painted.bottomRight), isTrue, reason: value); + expect(painted.width, lessThanOrEqualTo(inside.width), reason: value); // 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)); + expect(paragraph.size.width, greaterThan(40), reason: value); } }); }