Speak formula names, fit sheet value
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint) (push) Waiting to run
Tests / test (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-11-09 18:11:23 +01:00
parent c9c30b1ee5
commit 2dc502bbb6
5 changed files with 133 additions and 47 deletions

View File

@ -167,7 +167,13 @@ class _DatedValueCardState extends State<DatedValueCard> {
if (latest == null) if (latest == null)
Icon(Icons.block, size: 18, color: scheme.outline) Icon(Icons.block, size: 18, color: scheme.outline)
else 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( Row(

View File

@ -1,5 +1,5 @@
/// A kcal formula shown as a glyph + number; its proper name is only a /// A kcal formula shown as a glyph + number; its proper name is only the
/// long-press tooltip. /// glyph's spoken label.
library; library;
import 'package:diet_guard_app/services/activity_kcal.dart'; import 'package:diet_guard_app/services/activity_kcal.dart';
@ -13,7 +13,7 @@ const Map<String, IconData> formulaIcons = {
'Per-km': Icons.straighten, '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 { class FormulaGlyph extends StatelessWidget {
/// Creates a glyph for [name]. /// Creates a glyph for [name].
const FormulaGlyph(this.name, {this.size = 16, super.key}); const FormulaGlyph(this.name, {this.size = 16, super.key});
@ -24,14 +24,13 @@ class FormulaGlyph extends StatelessWidget {
/// Glyph size. /// Glyph size.
final double size; final double size;
// The proper name is spoken only: a tooltip paints on long-press.
@override @override
Widget build(BuildContext context) => Tooltip( Widget build(BuildContext context) => Icon(
message: name, formulaIcons[name],
child: Icon( size: size,
formulaIcons[name], color: Theme.of(context).colorScheme.onSurfaceVariant,
size: size, semanticLabel: name,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
); );
} }

View File

@ -3,6 +3,7 @@
library; library;
import 'package:diet_guard_app/services/body_energy.dart'; import 'package:diet_guard_app/services/body_energy.dart';
import 'package:diet_guard_app/widgets/body/formula_figure.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// Lose = down arrow, maintain = level bar, gain = up arrow. /// Lose = down arrow, maintain = level bar, gain = up arrow.
@ -20,24 +21,25 @@ IconData timeUnitIcon(String unit) => switch (unit) {
_ => Icons.event_repeat, _ => 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) { IconData activityIcon(String key) => switch (key) {
'sedentary' => Icons.chair, 'sedentary' => Icons.chair,
'light' => Icons.directions_walk, 'light' => Icons.directions_walk,
'moderate' => Icons.directions_bike, 'moderate' => Icons.directions_bike,
'very' => Icons.directions_run, 'very' => Icons.directions_run,
'extra' => Icons.fitness_center, 'extra' => Icons.fitness_center,
_ when key.startsWith(measuredPrefix) =>
formulaIcons[key.substring(measuredPrefix.length)] ?? Icons.speed,
_ => Icons.speed, _ => Icons.speed,
}; };
/// Short non-word caption under an activity icon: the TDEE multiplier of a /// Numeric caption under an activity icon: the TDEE multiplier of a level.
/// level, or the proper name of the measured formula. /// Null for a measured formula (its glyph names it; the proper name is only
String activityCaption(String key) { /// spoken) and for an unknown key.
if (key.startsWith(measuredPrefix)) { String? activityCaption(String key) {
return key.substring(measuredPrefix.length);
}
for (final level in activityLevels) { for (final level in activityLevels) {
if (level.key == key) return formatG(level.factor); if (level.key == key) return formatG(level.factor);
} }
return key; return null;
} }

View File

@ -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<String> _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<TestFailure>()));
});
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');
});
}

View File

@ -5,6 +5,8 @@ import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import '../../label_finders.dart';
Future<void> _pumpAt(WidgetTester tester, Size dp, Widget child) async { Future<void> _pumpAt(WidgetTester tester, Size dp, Widget child) async {
tester.view.physicalSize = dp; tester.view.physicalSize = dp;
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
@ -35,9 +37,7 @@ bool _paintsFormulaWord(Widget w) =>
].any((word) => (w.data ?? '').contains(word)); ].any((word) => (w.data ?? '').contains(word));
void main() { void main() {
testWidgets('activity formulas are icons; names only in tooltips', ( testWidgets('activity formulas are icons; names only spoken', (tester) async {
tester,
) async {
await _pumpAt( await _pumpAt(
tester, tester,
const Size(360, 2400), const Size(360, 2400),
@ -48,34 +48,19 @@ void main() {
), ),
); );
expect(find.byWidgetPredicate(_paintsFormulaWord), findsNothing); 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']) { for (final name in ['MET', 'ACSM', 'Per-km']) {
// One in the mean row, one in the session tile. // 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('300'), findsOneWidget);
expect(find.text('310'), findsOneWidget); expect(find.text('310'), findsOneWidget);
expect(find.text('320'), findsOneWidget); expect(find.text('320'), findsOneWidget);
// Distinct glyphs, so the three are told apart without words. // Distinct glyphs, so the three are told apart without words.
final icons = tester
.widgetList<Icon>(
find.descendant(
of: find.byTooltip('MET').first,
matching: find.byType(Icon),
),
)
.map((i) => i.icon)
.toSet();
expect(icons, hasLength(1));
final glyphs = { final glyphs = {
for (final name in ['MET', 'ACSM', 'Per-km']) for (final name in ['MET', 'ACSM', 'Per-km'])
(tester tester.widget<Icon>(byIconLabel(name).first).icon,
.widget<Icon>(
find.descendant(
of: find.byTooltip(name).first,
matching: find.byType(Icon),
),
)
.icon),
}; };
expect(glyphs, hasLength(3)); expect(glyphs, hasLength(3));
}); });
@ -98,11 +83,12 @@ void main() {
expect(find.byWidgetPredicate(_paintsFormulaWord), findsNothing); expect(find.byWidgetPredicate(_paintsFormulaWord), findsNothing);
}); });
for (final width in [360.0, 411.0]) { // A 360 dp phone, the Pixel's 411 dp, and a 4K web window.
testWidgets('a weight tile value fits whole at $width dp', (tester) async { 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( await _pumpAt(
tester, tester,
Size(width, 800), size,
Padding( Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: BodyTileGrid( child: BodyTileGrid(
@ -130,11 +116,14 @@ void main() {
expect(paragraph.didExceedMaxLines, isFalse, reason: value); expect(paragraph.didExceedMaxLines, isFalse, reason: value);
final card = find.ancestor(of: text, matching: find.byType(Card)); final card = find.ancestor(of: text, matching: find.byType(Card));
final inside = tester.getRect(card).inflate(0.5); final inside = tester.getRect(card).inflate(0.5);
expect(inside.contains(tester.getRect(text).topLeft), isTrue); // The painted rect (after FittedBox scaling) sits inside the tile.
expect(inside.contains(tester.getRect(text).bottomRight), isTrue); 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 full text is laid out (no ellipsis clipping): its width is
// the natural width of "70.0 kg" at this font. // 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);
} }
}); });
} }