diet-guard/app/lib/widgets/today_progress_card.dart
Krzysztof kuhy Rudnicki cbbebee79b 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
2026-08-16 14:18:39 +02:00

138 lines
4.7 KiB
Dart

/// Post-log feedback card for the log screen: what was just logged, plus
/// today's standing against the daily budget.
library;
import 'package:diet_guard_app/ui/theme.dart';
import 'package:flutter/material.dart';
/// Everything [TodayProgressCard] renders, computed by the caller.
///
/// A plain value object rather than a service lookup so the card stays a
/// pure function of its inputs -- the log screen already holds the freshly
/// written log when it builds one of these, so a second read would be both
/// redundant and a chance to race.
@immutable
class TodayProgress {
/// Creates a [TodayProgress].
const TodayProgress({
required this.consumedKcal,
required this.budgetKcal,
required this.proteinG,
required this.carbsG,
required this.fatG,
required this.adherenceStreak,
});
/// Today's total calories, including the meal just logged.
final double consumedKcal;
/// The budget in effect *today* (not the schedule -- the card is only ever
/// about the current day).
final int budgetKcal;
/// Today's total protein, grams.
final double proteinG;
/// Today's total carbohydrate, grams.
final double carbsG;
/// Today's total fat, grams.
final double fatG;
/// Consecutive-day budget-adherence streak, as of today.
final int adherenceStreak;
/// Calories still available today; negative once over budget.
double get remainingKcal => budgetKcal - consumedKcal;
/// Whether today's total has passed the budget.
bool get isOverBudget => remainingKcal < 0;
}
/// 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
/// externally-configured URL. Deliberately has no protein *target* -- that
/// derives from body weight, which only the PC stores (see
/// `diet_guard/_budget.py`'s `protein_target_g`), so the card reports
/// protein consumed and leaves targets to the gate's dashboard.
class TodayProgressCard extends StatelessWidget {
/// Creates a [TodayProgressCard].
const TodayProgressCard({required this.progress, super.key});
/// The values to display.
final TodayProgress progress;
static String _plural(int count) => count == 1 ? 'day' : 'days';
static String _grams(double value) => '${value.round()}g';
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final remaining = progress.remainingKcal.abs().round();
final remainingLabel = progress.isOverBudget
? '$remaining over'
: '$remaining left';
return Container(
width: double.infinity,
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
// Fill-only elevation: the shared design system forbids shadows in
// dark UI, and mixing a border with a fill on the same surface.
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
'${progress.consumedKcal.round()} / ${progress.budgetKcal}',
style: theme.textTheme.titleMedium?.copyWith(
color: progress.isOverBudget
? colors.error
: colors.onSurface,
),
),
const SizedBox(width: AppSpacing.xs),
Text('kcal', style: theme.textTheme.labelSmall),
const Spacer(),
Text(
remainingLabel,
style: theme.textTheme.labelMedium?.copyWith(
color: progress.isOverBudget
? colors.error
: colors.onSurfaceVariant,
),
),
],
),
const SizedBox(height: AppSpacing.xs),
Text(
'P ${_grams(progress.proteinG)} · '
'C ${_grams(progress.carbsG)} · '
'F ${_grams(progress.fatG)}',
style: theme.textTheme.labelSmall,
),
const SizedBox(height: AppSpacing.xs),
Text(
'Adherence streak: ${progress.adherenceStreak} '
'${_plural(progress.adherenceStreak)}',
style: theme.textTheme.labelSmall,
),
],
),
);
}
}