diet-guard/app/lib/screens/calendar_screen.dart
Krzysztof kuhy Rudnicki f7f06aee53
Some checks failed
pre-commit / pre-commit (push) Has been cancelled
Tests / test (3.10) (push) Has been cancelled
Tests / test (3.11) (push) Has been cancelled
Tests / test (3.12) (push) Has been cancelled
Remove unused features; fix reminder false alarms; share all state
Four asks, plus the fixes two review passes turned up.

Removals (all unused):
- "Repeat last meal" button, and the reward/temptation-bundling prompt it
  was the sole trigger for. Replaced with a post-log progress card showing
  kcal against budget, macros, and the adherence streak.
- The multi-item meal builder, on both the gate and the phone. The stored
  `components` field stays: nothing writes it now, but historical composite
  entries and the food bank still read it and it is part of the wire format.
- Meal photos entirely: 13 files, `imagePath`, the photo filter, the
  wrapper's /blobs/ route, image_picker, and the CAMERA permission.

Reminder false alarms had three real causes, all fixed:
- Logging never reconciled notifications, so a reminder sat on screen up to
  15 minutes after the meal was logged -- on the phone itself.
- The background check never pulled, so a PC-logged meal nagged forever
  until the app was opened. Now mirrors _cli_gate._should_lock: cheap local
  check, network only when a slot looks due, then re-check.
- Meals before 08:00 / after 22:00 were attributed to no slot at all. New
  slot_for_log/slotForLog clamps them to the first/last slot, byte-identical
  across Python and Dart. Deliberately separate from elapsed_slots, which
  would otherwise make every slot fall due at 23:00.

Budget history, so lowering the budget stops rewriting the past:
- Forward-only (effective_from, kcal) entries; each day is judged against
  the budget in force then. Verified live: setting 2000 left a 36-day
  adherence streak and the whole calendar pixel-identical.
- Rides as hist:<date> fields on the existing budget record. crdt_sync
  merges per-field over the union of field names and both devices push the
  merged log, so a device that predates the feature relays them untouched --
  no coordinated rollout, no second document.
- The seed guard tests emptiness, not file presence: _sync can write an
  empty history after merging with a pre-feature peer, and a presence check
  would then never seed again, silently reclassifying every past day.

One shared source of truth for every piece of data:
- Body weight now travels as its own `weight` CRDT field. Inside `value` it
  was collateral damage of whole-map LWW -- any phone budget edit deleted it,
  and the protein target with it.
- Both halves of the food bank sync. The curated half (foods added without
  eating them) was the last device-local state. The derived half merges with
  count as the clock, so LWW means max-count-wins; the log stays
  authoritative for membership, because a CRDT union never shrinks and a
  peer's stale copy would otherwise resurrect a deleted food permanently.
- Bank adapters honour tombstones, and Dart parses bank records defensively:
  a cast failure there is an Error, not an Exception, so one malformed
  remote record would have broken phone sync permanently.

_test_guard.py blocks any pathlib write to real user state while pytest is
in the process, whatever conftest is loaded. A throwaway test run outside
the package conftest destroyed a real GitHub token during this work; this
makes that class of accident impossible rather than merely discouraged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 16:39:52 +02:00

232 lines
6.8 KiB
Dart

/// Budget-adherence calendar, streaks, year-to-date tally, budget editing.
///
/// Mirrors the diet_guard gate's calendar tab (`_gatelock_calendar.py`): the
/// budget field starts read-only, defaulting to
/// [AppSettingsService.dailyKcalGoal]'s built-in 2200 kcal fallback when
/// nothing has ever been saved, and an "Edit"/"Save" toggle button unlocks
/// it for editing on the first click and validates+persists on the second.
library;
import 'dart:async';
import 'package:diet_guard_app/models/day_status.dart';
import 'package:diet_guard_app/screens/history_screen.dart';
import 'package:diet_guard_app/services/app_settings_service.dart';
import 'package:diet_guard_app/services/budget_history_service.dart';
import 'package:diet_guard_app/services/day_status_service.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:diet_guard_app/widgets/day_status_calendar.dart';
import 'package:diet_guard_app/widgets/streak_summary_row.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// Screen showing the budget-adherence calendar, streaks, and budget entry.
class CalendarScreen extends StatefulWidget {
/// Creates a [CalendarScreen].
const CalendarScreen({super.key});
@override
State<CalendarScreen> createState() => _CalendarScreenState();
}
class _CalendarScreenState extends State<CalendarScreen> {
final _budgetController = TextEditingController();
DateTime _month = DateTime.now();
Map<String, DayStatus> _statusByDate = {};
int _loggingStreak = 0;
int _adherenceStreak = 0;
YtdTally _tally = const YtdTally(
loggedDays: 0,
elapsedDays: 0,
adherentDays: 0,
);
bool _loading = true;
bool _editingBudget = false;
String? _budgetStatus;
bool _budgetError = false;
@override
void initState() {
super.initState();
unawaited(_load());
}
@override
void dispose() {
_budgetController.dispose();
super.dispose();
}
/// Loads the log, recomputes status/streaks/tally, and refreshes the
/// (read-only) budget field -- unless the user is mid-edit, in which case
/// their unsaved text is left untouched.
///
/// Each past day is judged against the budget that applied *then*, so
/// changing the budget today leaves the grid, the streaks, and the tally
/// for earlier days alone.
Future<void> _load() async {
final log = await LogStorageService.instance.readLog();
if (!mounted) return;
final budget = AppSettingsService.dailyKcalGoal;
final statusByDate = statusMap(
log,
schedule: BudgetHistoryService.schedule,
);
setState(() {
_statusByDate = statusByDate;
_loggingStreak = loggingStreak(statusByDate);
_adherenceStreak = adherenceStreak(statusByDate);
_tally = yearToDateTally(statusByDate);
_loading = false;
if (!_editingBudget) {
_budgetController.text = budget.toString();
}
});
}
void _onPrevMonth() {
setState(() => _month = DateTime(_month.year, _month.month - 1));
}
void _onNextMonth() {
setState(() => _month = DateTime(_month.year, _month.month + 1));
}
/// Opens [HistoryScreen] pre-filtered to the tapped day.
void _onDaySelected(DateTime day) {
unawaited(
Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) => HistoryScreen(
initialDateRange: DateTimeRange(start: day, end: day),
),
),
),
);
}
/// First click: unlocks the entry for typing, relabels the button "Save".
/// Second click: validates and persists; a validation failure leaves
/// editing open so the user can correct the value instead of silently
/// discarding it.
Future<void> _onEditOrSaveBudget() async {
if (!_editingBudget) {
setState(() {
_editingBudget = true;
_budgetStatus = null;
_budgetError = false;
});
return;
}
final value = int.tryParse(_budgetController.text.trim());
if (value == null) {
setState(() {
_budgetStatus = 'Enter a whole number of kcal.';
_budgetError = true;
});
return;
}
if (value <= 0) {
setState(() {
_budgetStatus = 'Budget must be a positive number.';
_budgetError = true;
});
return;
}
await AppSettingsService.instance.saveDailyKcalGoal(value);
if (!mounted) return;
setState(() {
_editingBudget = false;
_budgetStatus = 'Saved.';
_budgetError = false;
});
await _load();
}
@override
Widget build(BuildContext context) {
if (_loading) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(title: const Text('Calendar')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
children: [
_BudgetRow(
controller: _budgetController,
editing: _editingBudget,
onEditOrSave: _onEditOrSaveBudget,
),
if (_budgetStatus != null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
_budgetStatus!,
style: TextStyle(
color: _budgetError ? theme.colorScheme.error : null,
),
),
),
const SizedBox(height: 12),
DayStatusCalendar(
statusByDate: _statusByDate,
month: _month,
today: DateTime.now(),
onPrevMonth: _onPrevMonth,
onNextMonth: _onNextMonth,
onDaySelected: _onDaySelected,
),
const SizedBox(height: 16),
StreakSummaryRow(
loggingStreak: _loggingStreak,
adherenceStreak: _adherenceStreak,
tally: _tally,
),
],
),
),
);
}
}
class _BudgetRow extends StatelessWidget {
const _BudgetRow({
required this.controller,
required this.editing,
required this.onEditOrSave,
});
final TextEditingController controller;
final bool editing;
final VoidCallback onEditOrSave;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('Daily budget (kcal):'),
const SizedBox(width: 8),
SizedBox(
width: 90,
child: TextField(
controller: controller,
enabled: editing,
textAlign: TextAlign.center,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
),
),
const SizedBox(width: 8),
FilledButton(
onPressed: onEditOrSave,
child: Text(editing ? 'Save' : 'Edit'),
),
],
);
}
}