diff --git a/app/lib/screens/log_meal_progress.dart b/app/lib/screens/log_meal_progress.dart index 4d3a744..b97c244 100644 --- a/app/lib/screens/log_meal_progress.dart +++ b/app/lib/screens/log_meal_progress.dart @@ -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 []) @@ -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, diff --git a/app/lib/screens/log_meal_screen.dart b/app/lib/screens/log_meal_screen.dart index 2a1909a..ddf5c51 100644 --- a/app/lib/screens/log_meal_screen.dart +++ b/app/lib/screens/log_meal_screen.dart @@ -54,8 +54,6 @@ class _LogMealScreenState extends State String? _status; TodayProgress? _progress; - - @override void initState() { super.initState(); @@ -163,7 +161,7 @@ class _LogMealScreenState extends State if (!mounted) return; setState(() { _status = null; - _progress = buildTodayProgress(log, desc); + _progress = buildTodayProgress(log); }); } @@ -179,7 +177,6 @@ class _LogMealScreenState extends State /// the meal -- written before this runs -- can never be lost to one. Future _dismissStaleReminder() => checkAndNotify(pullWhenDue: false); - @override Widget build(BuildContext context) { return Scaffold( diff --git a/app/lib/widgets/slot_selector_row.dart b/app/lib/widgets/slot_selector_row.dart index 6cf4403..fdb02e8 100644 --- a/app/lib/widgets/slot_selector_row.dart +++ b/app/lib/widgets/slot_selector_row.dart @@ -49,42 +49,60 @@ class SlotSelectorRow extends StatelessWidget { final statusColors = Theme.of(context).extension() ?? 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), + ); + }), + ], + ), ); } } diff --git a/app/lib/widgets/today_progress_card.dart b/app/lib/widgets/today_progress_card.dart index 7ee9cc2..ed3e44a 100644 --- a/app/lib/widgets/today_progress_card.dart +++ b/app/lib/widgets/today_progress_card.dart @@ -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 "".` 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, diff --git a/app/pubspec.yaml b/app/pubspec.yaml index d1284ec..ee785e8 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.29+1 +version: 1.0.30+1 environment: sdk: ^3.12.2 diff --git a/app/test/screens/log_meal_screen_test.dart b/app/test/screens/log_meal_screen_test.dart index 8d44cb6..84f135d 100644 --- a/app/test/screens/log_meal_screen_test.dart +++ b/app/test/screens/log_meal_screen_test.dart @@ -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 "".` + // 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); diff --git a/app/test/widgets/slot_selector_row_test.dart b/app/test/widgets/slot_selector_row_test.dart index 42ab431..996082e 100644 --- a/app/test/widgets/slot_selector_row_test.dart +++ b/app/test/widgets/slot_selector_row_test.dart @@ -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 [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', + ); + } + } + }); }); } diff --git a/app/test/widgets/today_progress_card_test.dart b/app/test/widgets/today_progress_card_test.dart index e1d49e3..bc1aaad 100644 --- a/app/test/widgets/today_progress_card_test.dart +++ b/app/test/widgets/today_progress_card_test.dart @@ -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 "".` 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); diff --git a/refactor_claude_todo.md b/refactor_claude_todo.md deleted file mode 100644 index 4d478db..0000000 --- a/refactor_claude_todo.md +++ /dev/null @@ -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 ` 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).