diff --git a/app/lib/screens/log_meal_screen.dart b/app/lib/screens/log_meal_screen.dart index b7410f2..e240fa6 100644 --- a/app/lib/screens/log_meal_screen.dart +++ b/app/lib/screens/log_meal_screen.dart @@ -6,6 +6,7 @@ import 'dart:async'; import 'dart:io'; 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/nutrition.dart'; import 'package:diet_guard_app/models/slot.dart'; @@ -14,6 +15,7 @@ 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/meal_builder_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_sync_service.dart'; import 'package:diet_guard_app/services/foodbank_service.dart'; import 'package:diet_guard_app/services/log_storage_service.dart'; @@ -25,6 +27,7 @@ import 'package:diet_guard_app/widgets/photo_attach_field.dart'; import 'package:diet_guard_app/widgets/slot_selector_row.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; +import 'package:url_launcher/url_launcher.dart'; import 'package:workmanager/workmanager.dart'; /// Lets the user log one food item, with food-bank autocomplete and @@ -52,6 +55,8 @@ class _LogMealScreenState extends State String _source = 'manual'; String? _status; String? _imagePath; + FoodEntry? _lastEntry; + bool _showRewardPrompt = false; /// Single-flight guard so a launch sync and a lifecycle sync never overlap. bool _autoSyncing = false; @@ -75,6 +80,7 @@ class _LogMealScreenState extends State unawaited(_refreshSlots()); unawaited(_onDescChanged()); unawaited(_autoSync()); + unawaited(_refreshLastEntry()); } @override @@ -164,6 +170,15 @@ class _LogMealScreenState extends State setState(() => _loggedSlots = logged); } + /// Refreshes the entry "repeat last meal" would log, and its enabled + /// state. Called after every log (manual, built, or repeated) so the + /// button always repeats the most recent entry, not a stale one. + Future _refreshLastEntry() async { + final entry = await LogStorageService.instance.lastLoggedEntry(); + if (!mounted) return; + setState(() => _lastEntry = entry); + } + void _onMacroEdited() { if (_source == 'food bank') { setState(() => _source = 'manual'); @@ -175,7 +190,12 @@ class _LogMealScreenState extends State _descController.text, ); if (!mounted) return; - setState(() => _suggestions = matches); + setState(() { + _suggestions = matches; + // Starting to fill in the manual form dismisses a stale reward prompt + // left over from a prior one-tap "repeat last meal" log. + _showRewardPrompt = false; + }); } void _onSuggestionSelected(FoodSuggestion suggestion) { @@ -236,6 +256,49 @@ class _LogMealScreenState extends State await _refreshSlots(); if (!mounted) return; setState(() => _status = 'Logged "$desc".'); + await _refreshLastEntry(); + } + + /// One-tap "repeat last meal": re-logs [_lastEntry] verbatim (minus any + /// photo) against today's current slot. Mirrors [_onLogMeal]'s post-write + /// sequence exactly so offline/sync semantics stay identical. + Future _onLogLastMeal() async { + final last = _lastEntry; + if (last == null) return; + final nutrition = Nutrition( + kcal: last.kcal, + proteinG: last.proteinG, + carbsG: last.carbsG, + fatG: last.fatG, + grams: last.grams, + source: last.source, + ); + await LogStorageService.instance.logMeal( + last.desc, + nutrition, + slot: currentSlot(DateTime.now()), + components: last.components, + // No imagePath: a repeated meal shouldn't drag forward a photo of a + // specific past occasion. + ); + final log = await LogStorageService.instance.readLog(); + await FoodBankService.instance.rebuildAndPersist(log); + unawaited(_autoSync()); + unawaited(_enqueueSyncBackstop()); + if (!mounted) return; + await _refreshSlots(); + if (!mounted) return; + setState(() { + _status = 'Logged "${last.desc}" again.'; + _showRewardPrompt = AppSettingsService.rewardUrl?.isNotEmpty ?? false; + }); + await _refreshLastEntry(); + } + + Future _openReward() async { + final url = AppSettingsService.rewardUrl; + if (url == null || url.isEmpty) return; + await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); } Future _onBuildMeal() async { @@ -243,6 +306,7 @@ class _LogMealScreenState extends State MaterialPageRoute(builder: (_) => const MealBuilderScreen()), ); await _refreshSlots(); + await _refreshLastEntry(); // A meal built and logged in the builder should push right away too, // with the same offline backstop. unawaited(_autoSync()); @@ -311,8 +375,18 @@ class _LogMealScreenState extends State ), ], ), + floatingActionButton: Tooltip( + message: _lastEntry == null + ? 'No previous meal to repeat' + : 'Repeat "${_lastEntry!.desc}"', + child: FloatingActionButton.extended( + onPressed: _lastEntry == null ? null : _onLogLastMeal, + icon: const Icon(Icons.repeat), + label: const Text('Repeat last meal'), + ), + ), body: SingleChildScrollView( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.fromLTRB(16, 16, 16, 96), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -364,6 +438,17 @@ class _LogMealScreenState extends State const SizedBox(height: 12), Text(_status!), ], + if (_showRewardPrompt && + (AppSettingsService.rewardUrl?.isNotEmpty ?? false)) ...[ + const SizedBox(height: 8), + TextButton.icon( + onPressed: () => unawaited(_openReward()), + icon: const Icon(Icons.card_giftcard), + label: Text( + 'Open ${AppSettingsService.rewardLabel ?? "reward"}', + ), + ), + ], ], ), ), diff --git a/app/lib/screens/settings_screen.dart b/app/lib/screens/settings_screen.dart index 8133a48..cda8a93 100644 --- a/app/lib/screens/settings_screen.dart +++ b/app/lib/screens/settings_screen.dart @@ -43,6 +43,8 @@ class SettingsScreen extends StatefulWidget { class _SettingsScreenState extends State { final _kcalGoalController = TextEditingController(); + final _rewardLabelController = TextEditingController(); + final _rewardUrlController = TextEditingController(); final _ownerController = TextEditingController(); final _repoController = TextEditingController(); final _tokenController = TextEditingController(); @@ -69,6 +71,8 @@ class _SettingsScreenState extends State { } if (!mounted) return; _kcalGoalController.text = AppSettingsService.dailyKcalGoal.toString(); + _rewardLabelController.text = AppSettingsService.rewardLabel ?? ''; + _rewardUrlController.text = AppSettingsService.rewardUrl ?? ''; _ownerController.text = settings.owner; _repoController.text = settings.repo; _tokenController.text = settings.token; @@ -79,6 +83,8 @@ class _SettingsScreenState extends State { @override void dispose() { _kcalGoalController.dispose(); + _rewardLabelController.dispose(); + _rewardUrlController.dispose(); _ownerController.dispose(); _repoController.dispose(); _tokenController.dispose(); @@ -93,6 +99,16 @@ class _SettingsScreenState extends State { clientId: _clientIdController.text.trim(), ); + /// Persists the temptation-bundling reward fields, blank -> null. + Future _saveReward() async { + final label = _rewardLabelController.text.trim(); + final url = _rewardUrlController.text.trim(); + await AppSettingsService.instance.saveReward( + label: label.isEmpty ? null : label, + url: url.isEmpty ? null : url, + ); + } + void _showMessage(String message) { if (!mounted) return; setState(() => _status = message); @@ -256,6 +272,31 @@ class _SettingsScreenState extends State { const SizedBox(height: 24), const Divider(), const SizedBox(height: 8), + Text('Reward', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 4), + Text( + 'Shown after a one-tap "repeat last meal" log, as a temptation ' + 'bundle. Leave blank to disable. Device-local, not synced.', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 8), + TextField( + controller: _rewardLabelController, + decoration: const InputDecoration( + labelText: 'Reward label', + helperText: 'e.g. "Podcast episode"', + ), + onChanged: (v) => unawaited(_saveReward()), + ), + const SizedBox(height: 8), + TextField( + controller: _rewardUrlController, + decoration: const InputDecoration(labelText: 'Reward URL'), + onChanged: (v) => unawaited(_saveReward()), + ), + const SizedBox(height: 24), + const Divider(), + const SizedBox(height: 8), Text( 'Authorize in your browser — no token to paste. Syncs to ' 'kuhyx/syncs by default.', diff --git a/app/lib/services/app_settings_service.dart b/app/lib/services/app_settings_service.dart index ca604bf..c65eb85 100644 --- a/app/lib/services/app_settings_service.dart +++ b/app/lib/services/app_settings_service.dart @@ -28,6 +28,8 @@ class AppSettingsService { final File _file; int _dailyKcalGoal = 2200; DateTime? _dailyKcalGoalUpdatedAt; + String? _rewardLabel; + String? _rewardUrl; /// Returns the configured daily kcal goal, or 2200 when uninitialised. static int get dailyKcalGoal => _instance?._dailyKcalGoal ?? 2200; @@ -36,6 +38,17 @@ class AppSettingsService { static DateTime? get dailyKcalGoalUpdatedAt => _instance?._dailyKcalGoalUpdatedAt; + /// Returns the configured temptation-bundling reward label, or null. + /// + /// Device-local only -- no `updatedAt`/sync merge wiring, unlike the kcal + /// goal. Adding a sync-ready timestamp without real merge logic in + /// `sync_merge.dart`/`sync_service.dart` would imply cross-device behavior + /// that doesn't exist yet. + static String? get rewardLabel => _instance?._rewardLabel; + + /// Returns the configured temptation-bundling reward URL, or null. + static String? get rewardUrl => _instance?._rewardUrl; + /// Initialises the singleton, pointing at the app's documents directory. static Future init() async { if (_instance != null) return _instance!; @@ -87,6 +100,12 @@ class AppSettingsService { data['daily_kcal_goal_updated_at'] as String, ); } + if (data is Map && data['reward_label'] is String) { + _rewardLabel = data['reward_label'] as String; + } + if (data is Map && data['reward_url'] is String) { + _rewardUrl = data['reward_url'] as String; + } } on Exception { // Ignore parse errors and keep defaults. } @@ -107,14 +126,33 @@ class AppSettingsService { Future applySyncedBudget(int goal, {DateTime? updatedAt}) => _persist(goal, updatedAt); + /// Updates and persists the temptation-bundling reward shown after a + /// one-tap "repeat last meal" log. Pass `null` for either field to clear + /// it. + Future saveReward({String? label, String? url}) async { + _rewardLabel = label; + _rewardUrl = url; + await _writeToDisk(); + } + Future _persist(int goal, DateTime? updatedAt) async { _dailyKcalGoal = goal; _dailyKcalGoalUpdatedAt = updatedAt; + await _writeToDisk(); + } + + /// Writes the full current in-memory state to disk -- the single write + /// path every setter funnels through, so no two methods can race on + /// independently overwriting the same JSON file. + Future _writeToDisk() async { await _file.parent.create(recursive: true); await _file.writeAsString( jsonEncode({ - 'daily_kcal_goal': goal, - 'daily_kcal_goal_updated_at': updatedAt?.toIso8601String(), + 'daily_kcal_goal': _dailyKcalGoal, + 'daily_kcal_goal_updated_at': _dailyKcalGoalUpdatedAt + ?.toIso8601String(), + 'reward_label': _rewardLabel, + 'reward_url': _rewardUrl, }), ); } diff --git a/app/lib/services/log_storage_service.dart b/app/lib/services/log_storage_service.dart index ff05d7c..abf1de8 100644 --- a/app/lib/services/log_storage_service.dart +++ b/app/lib/services/log_storage_service.dart @@ -240,4 +240,15 @@ class LogStorageService { final entries = await todayEntries(); return entries.where((e) => e.slot != null).map((e) => e.slot!).toSet(); } + + /// Returns the single most recently logged, non-deleted entry across all + /// days, or `null` if nothing has ever been logged. + /// + /// Backs the "repeat last meal" one-tap action -- deliberately not scoped + /// to today or to the currently-due slot, since the point is "log the same + /// thing I just ate," which should work identically at any time of day. + Future lastLoggedEntry() async { + final entries = await allEntriesNewestFirst(); + return entries.isEmpty ? null : entries.first; + } } diff --git a/app/test/screens/log_meal_screen_sync_test.dart b/app/test/screens/log_meal_screen_sync_test.dart index 7a6a292..76d3859 100644 --- a/app/test/screens/log_meal_screen_sync_test.dart +++ b/app/test/screens/log_meal_screen_sync_test.dart @@ -3,6 +3,7 @@ import 'dart:io'; +import 'package:diet_guard_app/models/nutrition.dart'; import 'package:diet_guard_app/screens/log_meal_screen.dart'; import 'package:diet_guard_app/services/foodbank_service.dart'; import 'package:diet_guard_app/services/log_storage_service.dart'; @@ -174,4 +175,50 @@ void main() { expect(puts, 2); }); }); + + testWidgets('pushes immediately after repeating the last meal', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({ + 'sync.owner': 'o', + 'sync.repo': 'r', + }); + installFakeSecureStorage(initial: {'sync.token': 't'}); + var puts = 0; + final mock = MockClient((req) async { + if (req.method == 'PUT') puts++; + if (req.method == 'GET' && req.url.pathSegments.length == 3) { + return http.Response('{}', 200); + } + return http.Response('', 404); + }); + + await tester.runAsync(() async { + await LogStorageService.instance.logMeal( + 'seed meal', + const Nutrition( + kcal: 100, + proteinG: 5, + carbsG: 10, + fatG: 2, + grams: 50, + source: 'manual', + ), + ); + + await tester.pumpWidget( + MaterialApp(home: LogMealScreen(httpClient: mock)), + ); + await settle(tester); + expect(puts, 1); // launch push + + final fab = find.byType(FloatingActionButton); + await tester.ensureVisible(fab); + await tester.tap(fab); + await settle(tester); + + // The repeated meal is pushed right away too, same as manual logging. + expect(puts, 2); + }); + }); } diff --git a/app/test/screens/log_meal_screen_test.dart b/app/test/screens/log_meal_screen_test.dart index 641ba8e..404f017 100644 --- a/app/test/screens/log_meal_screen_test.dart +++ b/app/test/screens/log_meal_screen_test.dart @@ -1,6 +1,8 @@ import 'dart:io'; import 'package:diet_guard_app/models/food_entry.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/log_meal_screen.dart'; @@ -8,6 +10,7 @@ import 'package:diet_guard_app/screens/history_screen.dart'; import 'package:diet_guard_app/screens/meal_builder_screen.dart'; import 'package:diet_guard_app/screens/photo_viewer_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/foodbank_service.dart'; import 'package:diet_guard_app/services/log_storage_service.dart'; import 'package:diet_guard_app/services/photo_attach_service.dart'; @@ -15,6 +18,29 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:image_picker/image_picker.dart'; import 'package:image_picker_platform_interface/image_picker_platform_interface.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import 'package:url_launcher_platform_interface/link.dart'; +import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; + +/// Stub launcher that records the URL instead of opening it -- same pattern +/// as settings_screen_test.dart's fake, duplicated locally per this +/// codebase's convention of small per-file fakes over a shared mock. +class _FakeUrlLauncher extends UrlLauncherPlatform + with MockPlatformInterfaceMixin { + String? launched; + + @override + final LinkDelegate? linkDelegate = null; + + @override + Future supportsMode(PreferredLaunchMode mode) async => true; + + @override + Future launchUrl(String url, LaunchOptions options) async { + launched = url; + return true; + } +} /// Returns a fixed [XFile] without touching any real platform channel. class _FakeImagePickerPlatform extends ImagePickerPlatform { @@ -110,6 +136,7 @@ void main() { LogStorageService.resetForTesting(testDir: tempDir); FoodBankService.resetForTesting(testDir: tempDir); PhotoAttachService.resetForTesting(testDir: tempDir); + AppSettingsService.resetForTesting(testDir: tempDir); originalImagePickerPlatform = ImagePickerPlatform.instance; }); @@ -117,6 +144,7 @@ void main() { LogStorageService.resetForTesting(); FoodBankService.resetForTesting(); PhotoAttachService.resetForTesting(); + AppSettingsService.resetForTesting(); ImagePickerPlatform.instance = originalImagePickerPlatform; await tempDir.delete(recursive: true); }); @@ -483,4 +511,175 @@ void main() { expect(find.byType(LogMealScreen), findsOneWidget); }); }); + + const nutritionA = Nutrition( + kcal: 200, + proteinG: 10, + carbsG: 20, + fatG: 4, + grams: 100, + source: 'manual', + ); + + testWidgets( + 'repeat last meal FAB is disabled with no previous meal logged', + (tester) async { + await tester.runAsync(() async { + await tester.pumpWidget(const MaterialApp(home: LogMealScreen())); + await settle(tester); + + final fab = tester.widget( + find.byType(FloatingActionButton), + ); + expect(fab.onPressed, isNull); + expect(find.byTooltip('No previous meal to repeat'), findsOneWidget); + }); + }, + ); + + testWidgets( + 'tapping repeat last meal logs a duplicate of the most recent entry ' + "with today's current slot", + (tester) async { + await tester.runAsync(() async { + const older = FoodEntry( + id: 'older', + time: '2026-06-01T08:00:00+02:00', + desc: 'first meal', + grams: 100, + kcal: 100, + proteinG: 5, + carbsG: 10, + fatG: 2, + source: 'manual', + ); + await LogStorageService.instance.writeLog({ + '2026-06-01': [older], + }); + await LogStorageService.instance.logMeal('second meal', nutritionA); + + await tester.pumpWidget(const MaterialApp(home: LogMealScreen())); + await settle(tester); + + final fab = find.byType(FloatingActionButton); + await tester.ensureVisible(fab); + await tester.tap(fab); + await settle(tester); + + expect(find.text('Logged "second meal" again.'), findsOneWidget); + + final entries = await LogStorageService.instance.todayEntries(); + final repeated = entries.where((e) => e.desc == 'second meal'); + expect(repeated.length, 2); + // The seed entry (logged with no explicit slot) has slot: null; the + // repeat must be the one stamped with today's current slot. + expect( + repeated.any((e) => e.slot == currentSlot(DateTime.now())), + isTrue, + ); + }); + }, + ); + + testWidgets( + 'tapping repeat last meal twice keeps repeating the newly-logged entry', + (tester) async { + await tester.runAsync(() async { + await LogStorageService.instance.logMeal('looped meal', nutritionA); + + await tester.pumpWidget(const MaterialApp(home: LogMealScreen())); + await settle(tester); + + final fab = find.byType(FloatingActionButton); + await tester.ensureVisible(fab); + await tester.tap(fab); + await settle(tester); + await tester.tap(fab); + await settle(tester); + + final entries = await LogStorageService.instance.todayEntries(); + expect(entries.where((e) => e.desc == 'looped meal').length, 3); + }); + }, + ); + + testWidgets( + 'reward affordance is absent by default and appears after a repeat ' + 'once a reward is configured', + (tester) async { + await tester.runAsync(() async { + await LogStorageService.instance.logMeal('reward meal', nutritionA); + await AppSettingsService.instance.saveReward( + label: 'Podcast', + url: 'https://example.com/podcast', + ); + + await tester.pumpWidget(const MaterialApp(home: LogMealScreen())); + await settle(tester); + + expect(find.text('Open Podcast'), findsNothing); + + final fab = find.byType(FloatingActionButton); + await tester.ensureVisible(fab); + await tester.tap(fab); + await settle(tester); + + expect(find.text('Open Podcast'), findsOneWidget); + }); + }, + ); + + testWidgets('editing the description field dismisses a stale reward prompt', ( + tester, + ) async { + await tester.runAsync(() async { + await LogStorageService.instance.logMeal('reward meal', nutritionA); + await AppSettingsService.instance.saveReward( + label: 'Podcast', + url: 'https://example.com/podcast', + ); + + await tester.pumpWidget(const MaterialApp(home: LogMealScreen())); + await settle(tester); + + final fab = find.byType(FloatingActionButton); + await tester.ensureVisible(fab); + await tester.tap(fab); + await settle(tester); + expect(find.text('Open Podcast'), findsOneWidget); + + await tester.enterText(find.byType(TextField).at(0), 'x'); + await settle(tester); + + expect(find.text('Open Podcast'), findsNothing); + }); + }); + + testWidgets('tapping the reward affordance launches the configured URL', ( + tester, + ) async { + final launcher = _FakeUrlLauncher(); + UrlLauncherPlatform.instance = launcher; + + await tester.runAsync(() async { + await LogStorageService.instance.logMeal('reward meal', nutritionA); + await AppSettingsService.instance.saveReward( + label: 'Podcast', + url: 'https://example.com/podcast', + ); + + await tester.pumpWidget(const MaterialApp(home: LogMealScreen())); + await settle(tester); + + final fab = find.byType(FloatingActionButton); + await tester.ensureVisible(fab); + await tester.tap(fab); + await settle(tester); + + await tester.tap(find.text('Open Podcast')); + await settle(tester); + + expect(launcher.launched, 'https://example.com/podcast'); + }); + }); } diff --git a/app/test/screens/settings_screen_test.dart b/app/test/screens/settings_screen_test.dart index fca181c..9cf602d 100644 --- a/app/test/screens/settings_screen_test.dart +++ b/app/test/screens/settings_screen_test.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:io'; 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/foodbank_service.dart'; import 'package:diet_guard_app/services/log_storage_service.dart'; import 'package:flutter/material.dart'; @@ -44,6 +45,7 @@ void main() { tempDir = await Directory.systemTemp.createTemp('diet_guard_settings_'); LogStorageService.resetForTesting(testDir: tempDir); FoodBankService.resetForTesting(testDir: tempDir); + AppSettingsService.resetForTesting(testDir: tempDir); SharedPreferences.setMockInitialValues({}); installFakeSecureStorage(); }); @@ -51,6 +53,7 @@ void main() { tearDown(() async { LogStorageService.resetForTesting(); FoodBankService.resetForTesting(); + AppSettingsService.resetForTesting(); await tempDir.delete(recursive: true); }); @@ -95,6 +98,46 @@ void main() { }); }); + testWidgets('loads existing saved reward values into their fields', ( + tester, + ) async { + await tester.runAsync(() async { + await AppSettingsService.instance.saveReward( + label: 'Podcast', + url: 'https://example.com/podcast', + ); + + await tester.pumpWidget(const MaterialApp(home: SettingsScreen())); + await settle(tester); + + expect(find.widgetWithText(TextField, 'Podcast'), findsOneWidget); + expect( + find.widgetWithText(TextField, 'https://example.com/podcast'), + findsOneWidget, + ); + }); + }); + + testWidgets('typing into the reward fields persists them', (tester) async { + await tester.runAsync(() async { + await tester.pumpWidget(const MaterialApp(home: SettingsScreen())); + await settle(tester); + + await tester.enterText( + find.widgetWithText(TextField, 'Reward label'), + 'Podcast', + ); + await tester.enterText( + find.widgetWithText(TextField, 'Reward URL'), + 'https://example.com/podcast', + ); + await settle(tester); + + expect(AppSettingsService.rewardLabel, 'Podcast'); + expect(AppSettingsService.rewardUrl, 'https://example.com/podcast'); + }); + }); + testWidgets('Save persists the entered token', (tester) async { await tester.runAsync(() async { await tester.pumpWidget(const MaterialApp(home: SettingsScreen())); diff --git a/app/test/services/app_settings_service_test.dart b/app/test/services/app_settings_service_test.dart index b90d590..59d5529 100644 --- a/app/test/services/app_settings_service_test.dart +++ b/app/test/services/app_settings_service_test.dart @@ -202,5 +202,102 @@ void main() { expect(AppSettingsService.dailyKcalGoalUpdatedAt, isNull); }, ); + + test('loads reward_label and reward_url from an existing file', () async { + await File('${tempDir.path}/app_settings.json').writeAsString( + jsonEncode({ + 'daily_kcal_goal': 1600, + 'reward_label': 'Podcast', + 'reward_url': 'https://example.com/podcast', + }), + ); + + await AppSettingsService.initForTesting(tempDir); + + expect(AppSettingsService.rewardLabel, 'Podcast'); + expect(AppSettingsService.rewardUrl, 'https://example.com/podcast'); + }); + + test('reward fields stay null when the file has no such keys', () async { + await File( + '${tempDir.path}/app_settings.json', + ).writeAsString(jsonEncode({'daily_kcal_goal': 1600})); + + await AppSettingsService.initForTesting(tempDir); + + expect(AppSettingsService.rewardLabel, isNull); + expect(AppSettingsService.rewardUrl, isNull); + }); + + test('reward fields stay null when not strings', () async { + await File('${tempDir.path}/app_settings.json').writeAsString( + jsonEncode({ + 'daily_kcal_goal': 1600, + 'reward_label': 123, + 'reward_url': false, + }), + ); + + await AppSettingsService.initForTesting(tempDir); + + expect(AppSettingsService.rewardLabel, isNull); + expect(AppSettingsService.rewardUrl, isNull); + }); + }); + + group('rewardLabel / rewardUrl static getters', () { + test('default to null when singleton is uninitialised', () { + expect(AppSettingsService.rewardLabel, isNull); + expect(AppSettingsService.rewardUrl, isNull); + }); + }); + + group('saveReward', () { + test('persists label and url, readable back via static getters', () async { + AppSettingsService.resetForTesting(testDir: tempDir); + await AppSettingsService.instance.saveReward( + label: 'Podcast', + url: 'https://example.com/podcast', + ); + + expect(AppSettingsService.rewardLabel, 'Podcast'); + expect(AppSettingsService.rewardUrl, 'https://example.com/podcast'); + + final raw = await File( + '${tempDir.path}/app_settings.json', + ).readAsString(); + final data = jsonDecode(raw) as Map; + expect(data['reward_label'], 'Podcast'); + expect(data['reward_url'], 'https://example.com/podcast'); + }); + + test('clears fields back to null when saved as null', () async { + AppSettingsService.resetForTesting(testDir: tempDir); + await AppSettingsService.instance.saveReward( + label: 'Podcast', + url: 'https://example.com/podcast', + ); + await AppSettingsService.instance.saveReward(label: null, url: null); + + expect(AppSettingsService.rewardLabel, isNull); + expect(AppSettingsService.rewardUrl, isNull); + }); + + test('does not disturb the existing kcal goal fields', () async { + AppSettingsService.resetForTesting(testDir: tempDir); + await AppSettingsService.instance.saveDailyKcalGoal(1800); + await AppSettingsService.instance.saveReward( + label: 'Podcast', + url: 'https://example.com/podcast', + ); + + expect(AppSettingsService.dailyKcalGoal, 1800); + + final raw = await File( + '${tempDir.path}/app_settings.json', + ).readAsString(); + final data = jsonDecode(raw) as Map; + expect(data['daily_kcal_goal'], 1800); + }); }); } diff --git a/app/test/services/log_storage_service_test.dart b/app/test/services/log_storage_service_test.dart index b5531c2..718bd2c 100644 --- a/app/test/services/log_storage_service_test.dart +++ b/app/test/services/log_storage_service_test.dart @@ -381,4 +381,73 @@ void main() { expect(log['2026-06-22']!.first.desc, 'original desc'); }); }); + + group('lastLoggedEntry', () { + test('returns null for an empty log', () async { + expect(await LogStorageService.instance.lastLoggedEntry(), isNull); + }); + + test('returns the newest non-deleted entry across all days', () async { + const older = FoodEntry( + id: 'older', + time: '2026-06-01T08:00:00+02:00', + desc: 'older meal', + grams: 100, + kcal: 100, + proteinG: 5, + carbsG: 10, + fatG: 2, + source: 'manual', + ); + const newer = FoodEntry( + id: 'newer', + time: '2026-06-22T20:00:00+02:00', + desc: 'newer meal', + grams: 100, + kcal: 200, + proteinG: 10, + carbsG: 20, + fatG: 4, + source: 'manual', + ); + await LogStorageService.instance.writeLog({ + '2026-06-01': [older], + '2026-06-22': [newer], + }); + final last = await LogStorageService.instance.lastLoggedEntry(); + expect(last!.id, 'newer'); + }); + + test('skips a tombstoned newest entry and falls through', () async { + const tombstoned = FoodEntry( + id: 'gone', + time: '2026-06-22T20:00:00+02:00', + desc: 'undone meal', + grams: 100, + kcal: 300, + proteinG: 1, + carbsG: 1, + fatG: 1, + source: 'manual', + deleted: true, + ); + const kept = FoodEntry( + id: 'kept', + time: '2026-06-15T12:00:00+02:00', + desc: 'kept meal', + grams: 100, + kcal: 150, + proteinG: 5, + carbsG: 15, + fatG: 3, + source: 'manual', + ); + await LogStorageService.instance.writeLog({ + '2026-06-15': [kept], + '2026-06-22': [tombstoned], + }); + final last = await LogStorageService.instance.lastLoggedEntry(); + expect(last!.id, 'kept'); + }); + }); }