mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 20:27:11 +01:00
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:
parent
c0ed6154ac
commit
3c67cf8c9c
34
app/lib/screens/log_meal_actions.dart
Normal file
34
app/lib/screens/log_meal_actions.dart
Normal 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,
|
||||
),
|
||||
];
|
||||
45
app/lib/screens/log_meal_nav_mixin.dart
Normal file
45
app/lib/screens/log_meal_nav_mixin.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
36
app/lib/screens/log_meal_progress.dart
Normal file
36
app/lib/screens/log_meal_progress.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -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),
|
||||
),
|
||||
|
||||
140
app/lib/screens/log_meal_sync_mixin.dart
Normal file
140
app/lib/screens/log_meal_sync_mixin.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user