mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 18:07:20 +01:00
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
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:
parent
c9c30b1ee5
commit
2dc502bbb6
@ -167,7 +167,13 @@ class _DatedValueCardState extends State<DatedValueCard> {
|
||||
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(
|
||||
|
||||
@ -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<String, IconData> 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(
|
||||
Widget build(BuildContext context) => Icon(
|
||||
formulaIcons[name],
|
||||
size: size,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
semanticLabel: name,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
90
app/test/no_words_body_test.dart
Normal file
90
app/test/no_words_body_test.dart
Normal 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');
|
||||
});
|
||||
}
|
||||
@ -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<void> _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<Icon>(
|
||||
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<Icon>(
|
||||
find.descendant(
|
||||
of: find.byTooltip(name).first,
|
||||
matching: find.byType(Icon),
|
||||
),
|
||||
)
|
||||
.icon),
|
||||
tester.widget<Icon>(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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user