mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-12 08:30:51 +01:00
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>
232 lines
6.8 KiB
Dart
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'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|