mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-14 06:20:47 +01:00
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:
parent
cb23f3c09d
commit
cbbebee79b
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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).
|
||||
Loading…
Reference in New Issue
Block a user