app: one-tap repeat-last-meal FAB with optional temptation-bundling reward
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run

Applies Atomic Habits' two-minute rule: a FAB on the home screen repeats
the most recent logged meal in a single tap, mirroring _onLogMeal's
existing post-write sync sequence. An optional device-local reward
(label + URL, configured in Settings) surfaces after a repeat and opens
externally via url_launcher.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-07-20 10:09:36 +02:00
parent f6abf802c9
commit 10364dc1ff
9 changed files with 634 additions and 4 deletions

View File

@ -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<LogMealScreen>
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<LogMealScreen>
unawaited(_refreshSlots());
unawaited(_onDescChanged());
unawaited(_autoSync());
unawaited(_refreshLastEntry());
}
@override
@ -164,6 +170,15 @@ class _LogMealScreenState extends State<LogMealScreen>
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<void> _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<LogMealScreen>
_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<LogMealScreen>
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<void> _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<void> _openReward() async {
final url = AppSettingsService.rewardUrl;
if (url == null || url.isEmpty) return;
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
}
Future<void> _onBuildMeal() async {
@ -243,6 +306,7 @@ class _LogMealScreenState extends State<LogMealScreen>
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<LogMealScreen>
),
],
),
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<LogMealScreen>
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"}',
),
),
],
],
),
),

View File

@ -43,6 +43,8 @@ class SettingsScreen extends StatefulWidget {
class _SettingsScreenState extends State<SettingsScreen> {
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<SettingsScreen> {
}
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<SettingsScreen> {
@override
void dispose() {
_kcalGoalController.dispose();
_rewardLabelController.dispose();
_rewardUrlController.dispose();
_ownerController.dispose();
_repoController.dispose();
_tokenController.dispose();
@ -93,6 +99,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
clientId: _clientIdController.text.trim(),
);
/// Persists the temptation-bundling reward fields, blank -> null.
Future<void> _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<SettingsScreen> {
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.',

View File

@ -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<AppSettingsService> 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<void> 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<void> saveReward({String? label, String? url}) async {
_rewardLabel = label;
_rewardUrl = url;
await _writeToDisk();
}
Future<void> _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<void> _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,
}),
);
}

View File

@ -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<FoodEntry?> lastLoggedEntry() async {
final entries = await allEntriesNewestFirst();
return entries.isEmpty ? null : entries.first;
}
}

View File

@ -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);
});
});
}

View File

@ -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<bool> supportsMode(PreferredLaunchMode mode) async => true;
@override
Future<bool> 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<FloatingActionButton>(
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');
});
});
}

View File

@ -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()));

View File

@ -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);
});
});
}

View File

@ -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');
});
});
}