Split log_meal_screen into mixins and widget helpers

Twelfth batch of the 250-line sweep (over-cap count 5 -> 4).

  log_meal_screen.dart  431 -> 250

Widget/behaviour extraction only, no route changes: the four app-bar buttons
push exactly the MaterialPageRoutes they did before.

  log_meal_sync_mixin.dart  background sync + app lifecycle observation
  log_meal_nav_mixin.dart   the four app-bar destinations
  log_meal_actions.dart     the app-bar IconButton list
  log_meal_progress.dart    buildTodayProgress + parseMacroField (both pure)

The sync and nav halves are mixins rather than helper classes because the
behaviour is inseparable from the State it drives -- it observes the widget
lifecycle, calls setState, and reads `mounted`. The screen's fields stay
declared on the State; `syncHttpClient` is an abstract getter because `widget`
is typed `T` inside a mixin, so `widget.httpClient` does not resolve there.

Names crossing the new file boundaries became public with doc comments, as
`public_member_api_docs` requires. The macro-filling block moved onto
`MacroControllers.fillFrom`, next to the existing `clear()`, which is where it
belonged anyway.

Verified: flutter test 555 (unchanged), flutter analyze clean, pre-commit
clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC
This commit is contained in:
Krzysztof Rudnicki 2026-08-15 20:59:48 +02:00
parent c0ed6154ac
commit 3c67cf8c9c
7 changed files with 313 additions and 225 deletions

View File

@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
/// The four destination buttons in [LogMealScreen]'s app bar.
///
/// Split out of `log_meal_screen.dart` for the repo's 250-line cap. Returned
/// as a list to be spread into `AppBar.actions`, so the bar's structure and
/// the order of the buttons are unchanged.
List<Widget> logMealAppBarActions({
required VoidCallback onFoodBank,
required VoidCallback onHistory,
required VoidCallback onCalendar,
required VoidCallback onSettings,
}) => [
IconButton(
icon: const Icon(Icons.restaurant_menu),
tooltip: 'Food bank',
onPressed: onFoodBank,
),
IconButton(
icon: const Icon(Icons.history),
tooltip: 'History',
onPressed: onHistory,
),
IconButton(
icon: const Icon(Icons.calendar_month),
tooltip: 'Calendar',
onPressed: onCalendar,
),
IconButton(
icon: const Icon(Icons.settings),
tooltip: 'Sync settings',
onPressed: onSettings,
),
];

View File

@ -0,0 +1,45 @@
import 'package:diet_guard_app/screens/calendar_screen.dart';
import 'package:diet_guard_app/screens/food_bank_screen.dart';
import 'package:diet_guard_app/screens/history_screen.dart';
import 'package:diet_guard_app/screens/settings_screen.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
/// The four app-bar destinations reachable from [LogMealScreen].
///
/// Split out of `log_meal_screen.dart` for the repo's 250-line cap. The routes
/// are unchanged -- each pushes exactly the [MaterialPageRoute] it did before.
mixin LogMealNavMixin<T extends StatefulWidget> on State<T> {
/// The screen's injected HTTP client, forwarded to [SettingsScreen].
http.Client? get syncHttpClient;
/// Pushes the entry history screen.
void onOpenHistory() {
Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => const HistoryScreen()),
);
}
/// Pushes the adherence calendar screen.
void onOpenCalendar() {
Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => const CalendarScreen()),
);
}
/// Pushes the food bank screen.
void onOpenFoodBank() {
Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => const FoodBankScreen()),
);
}
/// Pushes the settings screen, forwarding the HTTP client.
void onOpenSettings() {
Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) => SettingsScreen(httpClient: syncHttpClient),
),
);
}
}

View File

@ -0,0 +1,36 @@
import 'package:diet_guard_app/models/food_entry.dart';
import 'package:diet_guard_app/models/local_time.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/today_progress_card.dart';
import 'package:flutter/widgets.dart';
/// Reads a macro field as a number, treating blank or unparsable as zero.
double parseMacroField(TextEditingController controller) =>
double.tryParse(controller.text.trim()) ?? 0;
/// Summarises today from the log just written.
///
/// 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) {
final budget = AppSettingsService.dailyKcalGoal;
final today = localDateKey(DateTime.now());
final entries = (log[today] ?? const <FoodEntry>[])
.where((entry) => !entry.deleted)
.toList();
final macros = sumMacros(entries);
return TodayProgress(
justLogged: desc,
consumedKcal: sumKcal(entries),
budgetKcal: budget,
proteinG: macros.proteinG,
carbsG: macros.carbsG,
fatG: macros.fatG,
adherenceStreak: adherenceStreak(
statusMap(log, schedule: BudgetHistoryService.schedule),
),
);
}

View File

@ -3,29 +3,18 @@
library;
import 'dart:async';
import 'dart:developer';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:diet_guard_app/models/food_entry.dart';
import 'package:diet_guard_app/models/food_suggestion.dart';
import 'package:diet_guard_app/models/local_time.dart';
import 'package:diet_guard_app/models/nutrition.dart';
import 'package:diet_guard_app/models/slot.dart';
import 'package:diet_guard_app/screens/calendar_screen.dart';
import 'package:diet_guard_app/screens/food_bank_screen.dart';
import 'package:diet_guard_app/screens/history_screen.dart';
import 'package:diet_guard_app/screens/settings_screen.dart';
import 'package:diet_guard_app/services/app_settings_service.dart';
import 'package:diet_guard_app/services/background_tasks.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/screens/log_meal_actions.dart';
import 'package:diet_guard_app/screens/log_meal_nav_mixin.dart';
import 'package:diet_guard_app/screens/log_meal_progress.dart';
import 'package:diet_guard_app/screens/log_meal_sync_mixin.dart';
import 'package:diet_guard_app/services/due_slot_check.dart';
import 'package:diet_guard_app/services/firebase_backend.dart';
import 'package:diet_guard_app/services/foodbank_service.dart';
import 'package:diet_guard_app/services/github_client_factory.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:diet_guard_app/services/sync_health.dart';
import 'package:diet_guard_app/services/sync_service.dart';
import 'package:diet_guard_app/services/sync_settings.dart';
import 'package:diet_guard_app/ui/theme.dart';
import 'package:diet_guard_app/widgets/autocomplete_suggestion_list.dart';
import 'package:diet_guard_app/widgets/macro_input_row.dart';
@ -50,21 +39,22 @@ class LogMealScreen extends StatefulWidget {
}
class _LogMealScreenState extends State<LogMealScreen>
with WidgetsBindingObserver {
with
WidgetsBindingObserver,
LogMealSyncMixin<LogMealScreen>,
LogMealNavMixin<LogMealScreen> {
@override
http.Client? get syncHttpClient => widget.httpClient;
final TextEditingController _descController = TextEditingController();
final MacroControllers _macros = MacroControllers();
List<FoodSuggestion> _suggestions = const [];
Set<int> _loggedSlots = {};
int? _selectedSlot;
String _source = 'manual';
String? _status;
TodayProgress? _progress;
/// Single-flight guard so a launch sync and a lifecycle sync never overlap.
bool _autoSyncing = false;
/// Latest sync health, driving the "not syncing" banner. Null until read.
SyncHealthStatus? _syncHealth;
@override
void initState() {
@ -82,12 +72,12 @@ class _LogMealScreenState extends State<LogMealScreen>
controller.addListener(_onMacroEdited);
}
_selectedSlot = slotForLog(DateTime.now());
unawaited(_refreshSlots());
unawaited(refreshSlots());
unawaited(_onDescChanged());
// Read health before the first sync finishes, so a device that stalled in
// a previous session says so immediately rather than only after a tick.
unawaited(_refreshSyncHealth());
unawaited(_autoSync());
unawaited(refreshSyncHealth());
unawaited(autoSync());
}
@override
@ -98,145 +88,37 @@ class _LogMealScreenState extends State<LogMealScreen>
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// Pull on resume (catch up on what another device logged while this one
// was backgrounded) and push on pause (keep the remote near-current).
final isResumeOrPause =
state == AppLifecycleState.resumed || state == AppLifecycleState.paused;
if (isResumeOrPause) {
unawaited(_autoSync());
}
}
/// Best-effort background sync: silent, skips when unconfigured, and never
/// overlaps itself. Failures are swallowed -- the Settings screen's manual
/// "Sync now" is where errors get surfaced. The try wraps even loading
/// [SyncSettings] itself: under `flutter test`, the shared_preferences and
/// secure-storage platform channels are unmocked by default and throw
/// [MissingPluginException], which must degrade exactly like "offline"
/// rather than crash every screen that mounts this widget.
///
/// [_refreshSlots] only runs after an actual sync (not on the unconfigured
/// path, which every existing screen test takes): a fire-and-forget tail
/// await here can resolve after a *later* test's `tearDown` has already
/// reset [LogStorageService]'s singleton -- `mounted` alone doesn't bound
/// that, since widget disposal between tests isn't synchronized with a
/// still-pending Future from an earlier one.
Future<void> _autoSync() async {
if (_autoSyncing) return;
_autoSyncing = true;
try {
final settings = await SyncSettings.load();
// Either backend counts. `isConfigured` means "has a GitHub token", so
// gating on it alone silently skips every sync on a device connected
// only to Firebase -- and once the mirror is retired that is every
// device. Same fix workout_app's push() already carries.
if (!settings.isConfigured && await openFirebase() == null) {
// Never a silent return: an unconfigured device looked exactly like a
// healthy one with nothing to send, which is how meals sat unpublished
// for days. Recorded so the banner can say so.
log(
'diet_guard auto-sync: no backend configured; nothing to push',
level: 900,
);
await SyncHealth.recordUnconfigured();
if (mounted) await _refreshSyncHealth();
return;
}
final client = createGitHubClient(
settings,
httpClient: widget.httpClient,
);
try {
await runSync(await syncBackend(client));
await SyncHealth.recordSuccess();
} finally {
client.close();
}
if (!mounted) return;
await _refreshSlots();
await _refreshSyncHealth();
} on Object catch (error, stackTrace) {
// Best-effort, but never silent: this swallowed the reason a desktop
// install could not publish at all, which looked identical to "nothing
// to sync". Offline and unmocked-platform-channel-under-test both land
// here too, so it stays non-fatal -- it just says why now.
log(
'diet_guard auto-sync failed',
level: 1000,
error: error,
stackTrace: stackTrace,
);
await SyncHealth.recordFailure();
if (mounted) await _refreshSyncHealth();
} finally {
_autoSyncing = false;
}
}
/// Re-reads sync health so the banner reflects the tick that just ran.
Future<void> _refreshSyncHealth() async {
final status = await SyncHealth.read();
if (!mounted) return;
setState(() => _syncHealth = status);
}
/// Queues the platform's offline push backstop so a meal logged while
/// offline still uploads on reconnect. The in-process [_autoSync] covers
/// the online case; this is the backstop (a no-op on web, which has no
/// out-of-page scheduler -- see background_tasks.dart).
// coverage:ignore-start
Future<void> _enqueueSyncBackstop() => enqueueSyncBackstop();
// coverage:ignore-end
Future<void> _refreshSlots() async {
final logged = await LogStorageService.instance.loggedSlotsToday();
if (!mounted) return;
setState(() => _loggedSlots = logged);
}
void _onMacroEdited() {
if (_source == 'food bank') {
setState(() => _source = 'manual');
}
}
/// Re-runs the food-bank search and dismisses the previous progress card.
///
/// The card is cleared only when the field is non-empty because
/// [_onLogMeal]'s own `clear()` fires this listener too: without the guard
/// the two async chains race and the clear can null the card right after
/// [_onLogMeal] set it, so it never appears on device (in-memory test
/// stores resolve fast enough to hide this).
Future<void> _onDescChanged() async {
final matches = await FoodBankService.instance.search(
_descController.text,
);
final matches = await FoodBankService.instance.search(_descController.text);
if (!mounted) return;
setState(() {
_suggestions = matches;
// Typing the *next* meal dismisses the previous log's progress card.
// Guarded on non-empty text because _onLogMeal's own
// `_descController.clear()` also fires this listener: without the
// guard the two async chains race and the clear can null the card
// right after _onLogMeal set it, so the card never appears on device
// (in-memory test stores resolve fast enough to hide this).
if (_descController.text.isNotEmpty) _progress = null;
});
}
void _onSuggestionSelected(FoodSuggestion suggestion) {
_descController.text = suggestion.name;
_macros.kcal.text = suggestion.nutrition.kcal.toStringAsFixed(0);
_macros.protein.text = suggestion.nutrition.proteinG.toStringAsFixed(0);
_macros.carbs.text = suggestion.nutrition.carbsG.toStringAsFixed(0);
_macros.fat.text = suggestion.nutrition.fatG.toStringAsFixed(0);
_macros.perGrams.text = suggestion.nutrition.grams.toStringAsFixed(0);
_macros.grams.text = suggestion.nutrition.grams.toStringAsFixed(0);
_macros.fillFrom(suggestion.nutrition);
setState(() {
_source = 'food bank';
_suggestions = const [];
});
}
double _parse(TextEditingController controller) =>
double.tryParse(controller.text.trim()) ?? 0;
Future<void> _onLogMeal() async {
final desc = _descController.text.trim();
if (desc.isEmpty) {
@ -247,12 +129,12 @@ class _LogMealScreenState extends State<LogMealScreen>
return;
}
final nutrition = nutritionForPortion(
kcal: _parse(_macros.kcal),
proteinG: _parse(_macros.protein),
carbsG: _parse(_macros.carbs),
fatG: _parse(_macros.fat),
perGrams: _parse(_macros.perGrams),
ateGrams: _parse(_macros.grams),
kcal: parseMacroField(_macros.kcal),
proteinG: parseMacroField(_macros.protein),
carbsG: parseMacroField(_macros.carbs),
fatG: parseMacroField(_macros.fat),
perGrams: parseMacroField(_macros.perGrams),
ateGrams: parseMacroField(_macros.grams),
source: _source,
);
await LogStorageService.instance.logMeal(
@ -264,11 +146,11 @@ class _LogMealScreenState extends State<LogMealScreen>
await FoodBankService.instance.rebuildAndPersist(log);
// Push the new meal now instead of waiting for the next lifecycle event,
// so the PC gate can see it in seconds. Fire-and-forget and best-effort:
// _autoSync is single-flight and swallows offline/transient failures.
unawaited(_autoSync());
// autoSync is single-flight and swallows offline/transient failures.
unawaited(autoSync());
// Offline backstop: if the push above fails (no connectivity), a
// connectivity-gated WorkManager task uploads the meal on reconnect.
unawaited(_enqueueSyncBackstop());
unawaited(enqueueSyncBackstopTask());
await _dismissStaleReminder();
if (!mounted) return;
_descController.clear();
@ -277,11 +159,11 @@ class _LogMealScreenState extends State<LogMealScreen>
_source = 'manual';
_selectedSlot = slotForLog(DateTime.now());
});
await _refreshSlots();
await refreshSlots();
if (!mounted) return;
setState(() {
_status = null;
_progress = _buildProgress(log, desc);
_progress = buildTodayProgress(log, desc);
});
}
@ -290,62 +172,13 @@ class _LogMealScreenState extends State<LogMealScreen>
/// Without this a notification already on screen survives until the next
/// background tick (up to 15 minutes), which reads as a false alarm even
/// though the meal was logged on this very device. Passes
/// `pullWhenDue: false` because [_autoSync] above already owns the network
/// `pullWhenDue: false` because [autoSync] above already owns the network
/// here and the local log is by definition the freshest copy.
///
/// [checkAndNotify] already swallows notification-platform failures, so
/// the meal -- written before this runs -- can never be lost to one.
Future<void> _dismissStaleReminder() => checkAndNotify(pullWhenDue: false);
/// Summarises today from the log just written.
///
/// Takes the already-read [log] rather than re-reading, so the card can
/// never disagree with the write that produced it.
TodayProgress _buildProgress(DayLog log, String desc) {
final budget = AppSettingsService.dailyKcalGoal;
final today = localDateKey(DateTime.now());
final entries = (log[today] ?? const <FoodEntry>[])
.where((entry) => !entry.deleted)
.toList();
final macros = sumMacros(entries);
return TodayProgress(
justLogged: desc,
consumedKcal: sumKcal(entries),
budgetKcal: budget,
proteinG: macros.proteinG,
carbsG: macros.carbsG,
fatG: macros.fatG,
adherenceStreak: adherenceStreak(
statusMap(log, schedule: BudgetHistoryService.schedule),
),
);
}
void _onOpenHistory() {
Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => const HistoryScreen()),
);
}
void _onOpenCalendar() {
Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => const CalendarScreen()),
);
}
void _onOpenFoodBank() {
Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => const FoodBankScreen()),
);
}
void _onOpenSettings() {
Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) => SettingsScreen(httpClient: widget.httpClient),
),
);
}
@override
Widget build(BuildContext context) {
@ -353,25 +186,11 @@ class _LogMealScreenState extends State<LogMealScreen>
appBar: AppBar(
title: const Text('Diet Guard'),
actions: [
IconButton(
icon: const Icon(Icons.restaurant_menu),
tooltip: 'Food bank',
onPressed: _onOpenFoodBank,
),
IconButton(
icon: const Icon(Icons.history),
tooltip: 'History',
onPressed: _onOpenHistory,
),
IconButton(
icon: const Icon(Icons.calendar_month),
tooltip: 'Calendar',
onPressed: _onOpenCalendar,
),
IconButton(
icon: const Icon(Icons.settings),
tooltip: 'Sync settings',
onPressed: _onOpenSettings,
...logMealAppBarActions(
onFoodBank: onOpenFoodBank,
onHistory: onOpenHistory,
onCalendar: onOpenCalendar,
onSettings: onOpenSettings,
),
],
),
@ -380,10 +199,10 @@ class _LogMealScreenState extends State<LogMealScreen>
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SyncHealthBanner(status: _syncHealth),
SyncHealthBanner(status: syncHealth),
SlotSelectorRow(
now: DateTime.now(),
loggedSlots: _loggedSlots,
loggedSlots: loggedSlots,
selectedSlot: _selectedSlot,
onSlotSelected: (slot) => setState(() => _selectedSlot = slot),
),

View File

@ -0,0 +1,140 @@
import 'dart:async';
import 'dart:developer';
import 'package:diet_guard_app/services/background_tasks.dart';
import 'package:diet_guard_app/services/firebase_backend.dart';
import 'package:diet_guard_app/services/github_client_factory.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:diet_guard_app/services/sync_health.dart';
import 'package:diet_guard_app/services/sync_service.dart';
import 'package:diet_guard_app/services/sync_settings.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
/// Background-sync and lifecycle behaviour for [LogMealScreen]'s state.
///
/// Split out of `log_meal_screen.dart` for the repo's 250-line cap. It is a
/// mixin rather than a helper class because the behaviour is inseparable from
/// the State it drives: it observes the widget lifecycle, calls `setState`,
/// and reads `mounted`. The screen's own fields stay declared on the State and
/// are reached here through the abstract members below, so the split moves
/// behaviour without moving ownership of the widget's state.
mixin LogMealSyncMixin<T extends StatefulWidget> on State<T>
implements WidgetsBindingObserver {
/// Single-flight guard so a launch sync and a lifecycle sync never overlap.
bool autoSyncing = false;
/// Latest sync health, driving the "not syncing" banner. Null until read.
SyncHealthStatus? syncHealth;
/// The slots that already carry a logged meal today.
Set<int> loggedSlots = {};
/// The screen's injected HTTP client, or null to use the default.
///
/// Declared abstract so the mixin stays independent of the widget's own
/// field: `widget` is typed `T` here, so `widget.httpClient` would not
/// resolve. The State supplies it.
http.Client? get syncHttpClient;
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// Pull on resume (catch up on what another device logged while this one
// was backgrounded) and push on pause (keep the remote near-current).
final isResumeOrPause =
state == AppLifecycleState.resumed || state == AppLifecycleState.paused;
if (isResumeOrPause) {
unawaited(autoSync());
}
}
/// Best-effort background sync: silent, skips when unconfigured, and never
/// overlaps itself. Failures are swallowed -- the Settings screen's manual
/// "Sync now" is where errors get surfaced. The try wraps even loading
/// [SyncSettings] itself: under `flutter test`, the shared_preferences and
/// secure-storage platform channels are unmocked by default and throw
/// [MissingPluginException], which must degrade exactly like "offline"
/// rather than crash every screen that mounts this widget.
///
/// [refreshSlots] only runs after an actual sync (not on the unconfigured
/// path, which every existing screen test takes): a fire-and-forget tail
/// await here can resolve after a *later* test's `tearDown` has already
/// reset [LogStorageService]'s singleton -- `mounted` alone doesn't bound
/// that, since widget disposal between tests isn't synchronized with a
/// still-pending Future from an earlier one.
Future<void> autoSync() async {
if (autoSyncing) return;
autoSyncing = true;
try {
final settings = await SyncSettings.load();
// Either backend counts. `isConfigured` means "has a GitHub token", so
// gating on it alone silently skips every sync on a device connected
// only to Firebase -- and once the mirror is retired that is every
// device. Same fix workout_app's push() already carries.
if (!settings.isConfigured && await openFirebase() == null) {
// Never a silent return: an unconfigured device looked exactly like a
// healthy one with nothing to send, which is how meals sat unpublished
// for days. Recorded so the banner can say so.
log(
'diet_guard auto-sync: no backend configured; nothing to push',
level: 900,
);
await SyncHealth.recordUnconfigured();
if (mounted) await refreshSyncHealth();
return;
}
final client = createGitHubClient(
settings,
httpClient: syncHttpClient,
);
try {
await runSync(await syncBackend(client));
await SyncHealth.recordSuccess();
} finally {
client.close();
}
if (!mounted) return;
await refreshSlots();
await refreshSyncHealth();
} on Object catch (error, stackTrace) {
// Best-effort, but never silent: this swallowed the reason a desktop
// install could not publish at all, which looked identical to "nothing
// to sync". Offline and unmocked-platform-channel-under-test both land
// here too, so it stays non-fatal -- it just says why now.
log(
'diet_guard auto-sync failed',
level: 1000,
error: error,
stackTrace: stackTrace,
);
await SyncHealth.recordFailure();
if (mounted) await refreshSyncHealth();
} finally {
autoSyncing = false;
}
}
/// Re-reads sync health so the banner reflects the tick that just ran.
Future<void> refreshSyncHealth() async {
final status = await SyncHealth.read();
if (!mounted) return;
setState(() => syncHealth = status);
}
/// Queues the platform's offline push backstop so a meal logged while
/// offline still uploads on reconnect. The in-process [autoSync] covers
/// the online case; this is the backstop (a no-op on web, which has no
/// out-of-page scheduler -- see background_tasks.dart).
// coverage:ignore-start
/// Schedules the WorkManager backstop that syncs when the app is closed.
Future<void> enqueueSyncBackstopTask() => enqueueSyncBackstop();
// coverage:ignore-end
/// Re-reads which meal slots are already logged today.
Future<void> refreshSlots() async {
final logged = await LogStorageService.instance.loggedSlotsToday();
if (!mounted) return;
setState(() => loggedSlots = logged);
}
}

View File

@ -3,6 +3,7 @@
/// directly and scaled to the amount actually eaten.
library;
import 'package:diet_guard_app/models/nutrition.dart';
import 'package:flutter/material.dart';
/// Text controllers for one macro-entry row, owned by the calling screen so
@ -38,6 +39,19 @@ class MacroControllers {
/// equals [perGrams].
final TextEditingController grams;
/// Fills every macro field from [nutrition], as a food-bank pick does.
///
/// Both [perGrams] and [grams] are set to the suggestion's own weight, so
/// the row starts out describing exactly the portion that was banked.
void fillFrom(Nutrition nutrition) {
kcal.text = nutrition.kcal.toStringAsFixed(0);
protein.text = nutrition.proteinG.toStringAsFixed(0);
carbs.text = nutrition.carbsG.toStringAsFixed(0);
fat.text = nutrition.fatG.toStringAsFixed(0);
perGrams.text = nutrition.grams.toStringAsFixed(0);
grams.text = nutrition.grams.toStringAsFixed(0);
}
/// Clears every field's text.
void clear() {
kcal.clear();

View File

@ -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.25+1
version: 1.0.26+1
environment:
sdk: ^3.12.2