From 3c67cf8c9cd958beed00f1bb0889993cf2daa89c Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 15 Aug 2026 20:59:48 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC --- app/lib/screens/log_meal_actions.dart | 34 +++ app/lib/screens/log_meal_nav_mixin.dart | 45 ++++ app/lib/screens/log_meal_progress.dart | 36 +++ app/lib/screens/log_meal_screen.dart | 267 ++++------------------- app/lib/screens/log_meal_sync_mixin.dart | 140 ++++++++++++ app/lib/widgets/macro_input_row.dart | 14 ++ app/pubspec.yaml | 2 +- 7 files changed, 313 insertions(+), 225 deletions(-) create mode 100644 app/lib/screens/log_meal_actions.dart create mode 100644 app/lib/screens/log_meal_nav_mixin.dart create mode 100644 app/lib/screens/log_meal_progress.dart create mode 100644 app/lib/screens/log_meal_sync_mixin.dart diff --git a/app/lib/screens/log_meal_actions.dart b/app/lib/screens/log_meal_actions.dart new file mode 100644 index 0000000..fb7571d --- /dev/null +++ b/app/lib/screens/log_meal_actions.dart @@ -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 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, + ), +]; diff --git a/app/lib/screens/log_meal_nav_mixin.dart b/app/lib/screens/log_meal_nav_mixin.dart new file mode 100644 index 0000000..9a80a59 --- /dev/null +++ b/app/lib/screens/log_meal_nav_mixin.dart @@ -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 on State { + /// The screen's injected HTTP client, forwarded to [SettingsScreen]. + http.Client? get syncHttpClient; + + /// Pushes the entry history screen. + void onOpenHistory() { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const HistoryScreen()), + ); + } + + /// Pushes the adherence calendar screen. + void onOpenCalendar() { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const CalendarScreen()), + ); + } + + /// Pushes the food bank screen. + void onOpenFoodBank() { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const FoodBankScreen()), + ); + } + + /// Pushes the settings screen, forwarding the HTTP client. + void onOpenSettings() { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => SettingsScreen(httpClient: syncHttpClient), + ), + ); + } +} diff --git a/app/lib/screens/log_meal_progress.dart b/app/lib/screens/log_meal_progress.dart new file mode 100644 index 0000000..4d3a744 --- /dev/null +++ b/app/lib/screens/log_meal_progress.dart @@ -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 []) + .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), + ), + ); +} diff --git a/app/lib/screens/log_meal_screen.dart b/app/lib/screens/log_meal_screen.dart index e16f901..2a1909a 100644 --- a/app/lib/screens/log_meal_screen.dart +++ b/app/lib/screens/log_meal_screen.dart @@ -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 - with WidgetsBindingObserver { + with + WidgetsBindingObserver, + LogMealSyncMixin, + LogMealNavMixin { + @override + http.Client? get syncHttpClient => widget.httpClient; + final TextEditingController _descController = TextEditingController(); final MacroControllers _macros = MacroControllers(); List _suggestions = const []; - Set _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 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 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 _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 _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 _enqueueSyncBackstop() => enqueueSyncBackstop(); - - // coverage:ignore-end - - Future _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 _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 _onLogMeal() async { final desc = _descController.text.trim(); if (desc.isEmpty) { @@ -247,12 +129,12 @@ class _LogMealScreenState extends State 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 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 _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 /// 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 _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 []) - .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( - MaterialPageRoute(builder: (_) => const HistoryScreen()), - ); - } - - void _onOpenCalendar() { - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const CalendarScreen()), - ); - } - - void _onOpenFoodBank() { - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const FoodBankScreen()), - ); - } - - void _onOpenSettings() { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => SettingsScreen(httpClient: widget.httpClient), - ), - ); - } @override Widget build(BuildContext context) { @@ -353,25 +186,11 @@ class _LogMealScreenState extends State 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 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), ), diff --git a/app/lib/screens/log_meal_sync_mixin.dart b/app/lib/screens/log_meal_sync_mixin.dart new file mode 100644 index 0000000..97e06de --- /dev/null +++ b/app/lib/screens/log_meal_sync_mixin.dart @@ -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 on State + 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 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 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 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 enqueueSyncBackstopTask() => enqueueSyncBackstop(); + + // coverage:ignore-end + + /// Re-reads which meal slots are already logged today. + Future refreshSlots() async { + final logged = await LogStorageService.instance.loggedSlotsToday(); + if (!mounted) return; + setState(() => loggedSlots = logged); + } +} diff --git a/app/lib/widgets/macro_input_row.dart b/app/lib/widgets/macro_input_row.dart index d9ca83f..2cb8cf0 100644 --- a/app/lib/widgets/macro_input_row.dart +++ b/app/lib/widgets/macro_input_row.dart @@ -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(); diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 75fad6b..085d7b4 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -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