Pin meal pills to one row; drop the "Logged" restatement

Three independent cleanups ahead of the configurable meal schedule.

Pills: the row was a `Wrap`, so pills dropped onto a second line as soon as
they outgrew the width -- which they do even at four. Measured: a logged chip
is ~124px at Material defaults, so four need 522px against ~330px of phone
width. Now a `FittedBox(scaleDown)` over a `Row`, which scales the row down
uniformly, plus compact chip density so there is less to scale away.

A `Row` of `Expanded` children was the obvious alternative and is wrong: it
imposes a tight width, and a Chip narrower than avatar+padding+label clips its
label rather than shrinking -- measured at 10px per label, about one character,
while throwing no overflow error. The new regression test therefore asserts
both a shared baseline and a legible label width; it was confirmed to fail on
the old `Wrap` ("chip wrapped to a second line at 320.0") rather than passing
vacuously.

Card: the `Logged "<meal>".` line restated the description the user had just
typed. Removed, along with `TodayProgress.justLogged`, which nothing else read.
The kcal position, macros and streak stay.

refactor_claude_todo.md: the 250-line file-splitting refactor it tracked is
done -- `scripts/check_file_length.sh --all` exits 0 and an independent sweep
finds nothing over the cap. DESIGN_AUDIT_TODO.md is a separate, still-open
effort and stays.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159b4E5hh512jYUeaJVNxfM
This commit is contained in:
Krzysztof Rudnicki 2026-08-16 14:18:39 +02:00
parent cb23f3c09d
commit cbbebee79b
9 changed files with 111 additions and 155 deletions

View File

@ -15,7 +15,7 @@ double parseMacroField(TextEditingController controller) =>
///
/// Takes the already-read [log] rather than re-reading, so the card can
/// never disagree with the write that produced it.
TodayProgress buildTodayProgress(DayLog log, String desc) {
TodayProgress buildTodayProgress(DayLog log) {
final budget = AppSettingsService.dailyKcalGoal;
final today = localDateKey(DateTime.now());
final entries = (log[today] ?? const <FoodEntry>[])
@ -23,7 +23,6 @@ TodayProgress buildTodayProgress(DayLog log, String desc) {
.toList();
final macros = sumMacros(entries);
return TodayProgress(
justLogged: desc,
consumedKcal: sumKcal(entries),
budgetKcal: budget,
proteinG: macros.proteinG,

View File

@ -54,8 +54,6 @@ class _LogMealScreenState extends State<LogMealScreen>
String? _status;
TodayProgress? _progress;
@override
void initState() {
super.initState();
@ -163,7 +161,7 @@ class _LogMealScreenState extends State<LogMealScreen>
if (!mounted) return;
setState(() {
_status = null;
_progress = buildTodayProgress(log, desc);
_progress = buildTodayProgress(log);
});
}
@ -179,7 +177,6 @@ class _LogMealScreenState extends State<LogMealScreen>
/// the meal -- written before this runs -- can never be lost to one.
Future<void> _dismissStaleReminder() => checkAndNotify(pullWhenDue: false);
@override
Widget build(BuildContext context) {
return Scaffold(

View File

@ -49,42 +49,60 @@ class SlotSelectorRow extends StatelessWidget {
final statusColors =
Theme.of(context).extension<AppStatusColors>() ?? AppStatusColors.dark;
final elapsed = elapsedSlots(now).toSet();
return Wrap(
spacing: AppSpacing.xs + 2,
runSpacing: AppSpacing.xs,
children: [
...daySlots().map((slot) {
final isLogged = loggedSlots.contains(slot);
final isDue = !isLogged && elapsed.contains(slot);
final color = isLogged
? statusColors.success
: isDue
? scheme.error
: scheme.onSurfaceVariant;
final isSelected = selectedSlot == slot;
return ChoiceChip(
label: Text(slotLabel(slot)),
selected: isSelected,
// Icon reads lighter than the label text (rule 28): reduced
// opacity instead of the label's full-strength color.
avatar: isLogged
? Icon(
Icons.check,
size: 14,
color: color.withValues(alpha: 0.72),
)
: null,
backgroundColor: color.withValues(alpha: 0.15),
selectedColor: color.withValues(alpha: 0.35),
labelStyle: TextStyle(color: color),
side: BorderSide(
width: isSelected ? 2 : 1,
color: isSelected ? color : color.withValues(alpha: 0.4),
),
onSelected: (_) => onSlotSelected(slot),
);
}),
],
// One row, always. A Wrap dropped the later pills onto a second line as
// soon as the row outgrew the width, which it does even at four: a logged
// chip measures ~124px at Material defaults, so four already need 522px
// against ~330px of phone width.
//
// FittedBox scales the whole row down uniformly instead. A Row of
// Expanded children also avoids the wrap, but imposes a *tight* width on
// each chip, and a Chip narrower than avatar+padding+label clips its label
// rather than shrinking -- measured at 10px per label, about one
// character, and it throws no overflow error while doing it.
return FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: AppSpacing.xs + 2,
children: [
...daySlots().map((slot) {
final isLogged = loggedSlots.contains(slot);
final isDue = !isLogged && elapsed.contains(slot);
final color = isLogged
? statusColors.success
: isDue
? scheme.error
: scheme.onSurfaceVariant;
final isSelected = selectedSlot == slot;
return ChoiceChip(
label: Text(slotLabel(slot)),
selected: isSelected,
// Icon reads lighter than the label text (rule 28): reduced
// opacity instead of the label's full-strength color.
avatar: isLogged
? Icon(
Icons.check,
size: 14,
color: color.withValues(alpha: 0.72),
)
: null,
backgroundColor: color.withValues(alpha: 0.15),
selectedColor: color.withValues(alpha: 0.35),
labelStyle: TextStyle(color: color),
side: BorderSide(
width: isSelected ? 2 : 1,
color: isSelected ? color : color.withValues(alpha: 0.4),
),
// Trim the Material defaults so the row needs less scaling down:
// every pixel saved here is legibility kept at six meals.
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
onSelected: (_) => onSlotSelected(slot),
);
}),
],
),
);
}
}

View File

@ -15,7 +15,6 @@ import 'package:flutter/material.dart';
class TodayProgress {
/// Creates a [TodayProgress].
const TodayProgress({
required this.justLogged,
required this.consumedKcal,
required this.budgetKcal,
required this.proteinG,
@ -24,9 +23,6 @@ class TodayProgress {
required this.adherenceStreak,
});
/// Description of the meal that was just logged.
final String justLogged;
/// Today's total calories, including the meal just logged.
final double consumedKcal;
@ -53,7 +49,11 @@ class TodayProgress {
bool get isOverBudget => remainingKcal < 0;
}
/// Shows the just-logged meal and today's running budget position.
/// Shows today's running budget position after a meal is logged.
///
/// It used to open with a `Logged "<meal>".` line, removed 2026-08-16: the
/// user had just typed that description, so it restated the input instead of
/// telling them anything. What remains is what they act on.
///
/// Replaces the old one-tap-reward prompt: the same slot in the log screen,
/// but showing information the user actually acts on instead of an
@ -92,11 +92,6 @@ class TodayProgressCard extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Logged "${progress.justLogged}".',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: AppSpacing.sm),
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,

View File

@ -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.29+1
version: 1.0.30+1
environment:
sdk: ^3.12.2

View File

@ -40,7 +40,6 @@ class _FakeUrlLauncher extends UrlLauncherPlatform
/// it as a real image instead of throwing on bogus bytes.
void main() {
late Directory tempDir;
setUp(() async {
@ -94,7 +93,10 @@ void main() {
await tester.tap(logMealButton);
await settle(tester);
expect(find.text('Logged "toast".'), findsOneWidget);
// The card itself is the "it logged" signal; the `Logged "<meal>".`
// line it used to open with was removed 2026-08-16.
expect(find.byType(TodayProgressCard), findsOneWidget);
expect(find.textContaining('Logged'), findsNothing);
final entries = await LogStorageService.instance.todayEntries();
expect(entries.single.source, 'manual');
expect(entries.single.kcal, 150);

View File

@ -86,5 +86,46 @@ void main() {
);
expect(chip.selected, isTrue);
});
// The row used to be a Wrap, which pushed the later pills onto a second
// line: a logged chip is ~124px at Material defaults, so even four need
// more width than a phone has. Both halves below matter -- a Row of
// Expanded children also passes the "one line" check while squeezing each
// label to ~10px (about one character) and throwing nothing at all.
testWidgets('keeps every chip on one legible row at phone widths', (
tester,
) async {
for (final width in <double>[320, 360, 412]) {
tester.view.physicalSize = Size(width, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
// Every slot logged is the widest case: each chip carries a check
// avatar on top of its label.
await _pump(
tester,
now: DateTime(2026, 8, 14, 21),
loggedSlots: daySlots().toSet(),
);
expect(tester.takeException(), isNull, reason: 'overflowed at $width');
final labels = daySlots()
.map((slot) => tester.getRect(find.text(slotLabel(slot))))
.toList();
for (final label in labels) {
expect(
label.top,
moreOrLessEquals(labels.first.top, epsilon: 0.5),
reason: 'chip wrapped to a second line at $width',
);
expect(
label.width,
greaterThan(20),
reason: 'label squeezed to ${label.width}px at $width',
);
}
}
});
});
}

View File

@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
TodayProgress _progress({
String justLogged = 'toast',
double consumedKcal = 1450,
int budgetKcal = 2200,
double proteinG = 90,
@ -11,7 +10,6 @@ TodayProgress _progress({
double fatG = 40,
int adherenceStreak = 3,
}) => TodayProgress(
justLogged: justLogged,
consumedKcal: consumedKcal,
budgetKcal: budgetKcal,
proteinG: proteinG,
@ -48,12 +46,14 @@ void main() {
});
group('TodayProgressCard', () {
testWidgets('shows the meal, the kcal position, and macros', (
testWidgets('shows the kcal position and macros, but not the meal', (
tester,
) async {
await _pump(tester, _progress());
expect(find.text('Logged "toast".'), findsOneWidget);
// The `Logged "<meal>".` line was removed 2026-08-16 -- it restated
// what the user had just typed. Pinned so it does not creep back.
expect(find.textContaining('Logged'), findsNothing);
expect(find.text('1450 / 2200'), findsOneWidget);
expect(find.text('750 left'), findsOneWidget);
expect(find.text('P 90g · C 120g · F 40g'), findsOneWidget);

View File

@ -1,96 +0,0 @@
# Refactor TODO — enforce the 250-line file cap
> **This file is a ready-to-use prompt.** Paste it to Claude, or open this repo
> and say "do refactor_claude_todo". It is self-contained: everything needed to
> execute is below. Generated 2026-08-14 from a measured survey of every repo.
## Goal
Every file in this repo must be **at most 250 lines** — source, tests, and
prose (`.md`/`.txt`/`.rst`/`.tex`) alike — and must **stay** that way forever,
enforced by a gate that fails the commit, not by a note anyone can ignore.
Why: a file that cannot be read in one piece forces re-reads and partial edits,
which is the single largest avoidable cost in an LLM-assisted workflow. Aim by
churn, not size alone — refactoring pays where code is read and changed often
(Fowler, _refactoring economic benefit_).
## Scope in this repo
- **53 files** currently exceed 250 lines (of 222 eligible files).
- **21,714 lines** sit in violation; longest file is **1101 lines**.
Exempt (do NOT split these):
- generated files — `*.g.dart`, `*.freezed.dart`, `*.gr.dart`, `**/l10n/generated/**`,
anything with a `GENERATED` header
- markup — `.html`, `.css`, `.scss`
- data files — `.json`, `.yaml`, `.csv`, wordlists and other data-ish `.txt`
(mean line length under 25 chars)
## Violations, highest ROI first
ROI = lines x commits in the last year. Work top-down; a long file nobody edits
has near-zero payoff and should not be first.
| lines | commits/yr | kind | file |
| ----: | ---------: | :---- | :------------------------------------------- |
| 1101 | 12 | prose | `CLAUDE.md` |
| 440 | 15 | code | `app/lib/screens/log_meal_screen.dart` |
| 1052 | 6 | code | `app/lib/screens/history_screen.dart` |
| 1017 | 6 | code | `app/test/screens/history_screen_test.dart` |
| 498 | 10 | code | `diet_guard/_sync.py` |
| 286 | 16 | code | `app/lib/screens/settings_screen.dart` |
| 482 | 9 | code | `diet_guard/tests/test_sync.py` |
| 694 | 5 | code | `app/test/services/sync_merge_test.dart` |
| 472 | 7 | code | `diet_guard/_gatelock_ui.py` |
| 466 | 7 | code | `diet_guard/_cli.py` |
| 282 | 11 | code | `diet_guard/tests/conftest.py` |
| 274 | 11 | code | `app/test/screens/settings_screen_test.dart` |
| 362 | 8 | code | `app/test/screens/log_meal_screen_test.dart` |
| 357 | 8 | code | `diet_guard/_state.py` |
| 475 | 6 | code | `app/lib/services/sync_merge.dart` |
_(38 further files over 250 lines not listed — re-run the survey for the full set.)_
## How to split
- **Python** — extract cohesive helpers into sibling modules; keep the public
API and imports stable.
- **Shell** — split into `lib/*.sh` sourced by a thin entry script. Keep
`set -euo pipefail` in each.
- **Dart / TypeScript** — extract widgets/components into their own files.
- **Tests** — split by test-group into sibling files
(`foo.test.ts` -> `foo.parsing.test.ts`, `foo.render.test.ts`). Coverage must
not drop.
- **Docs** — split into topic files under `docs/` with an index. For an
oversized `CLAUDE.md`, move detail into referenced docs so the
always-loaded part shrinks.
**Do not** game the cap: no one-lining, no deleting tests, no moving code into
an exempt extension, no `# noqa`-style suppressions.
## Make it permanent (required — this is the point)
A refactor without a gate silently regrows. Before this task is done:
1. Wire the shared gate `~/utils/scripts/check_file_length.sh` into this repo's
`.pre-commit-config.yaml` as a local hook. If the repo has no pre-commit
config, add a minimal one.
2. The hook checks **files in the commit** (not the whole tree), so unrelated
commits never break, and it **fails** — exit 1, not a warning.
3. No baseline file and no allowlist. Those are suppressions.
4. If this repo has CI (`.github/workflows`), add the same check there so it
also fails on push.
## Done condition
- `bash ~/utils/scripts/check_file_length.sh --all` from this repo root exits 0.
- The repo's own test suite and coverage bar are still green.
- `pre-commit run --files <changed files>` passes.
- A deliberately over-250-line test file, staged, makes `git commit` **fail**.
- For a deployed daemon/app: the entry point still actually runs.
## Verify
Run the Python suite AND the Flutter app tests. Verify the companion app on the phone via the `phone-deploy` skill (`adb install -r`, never uninstall).