mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 19:07:15 +01:00
app: one-tap repeat-last-meal FAB with optional temptation-bundling reward
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:
parent
f6abf802c9
commit
10364dc1ff
@ -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"}',
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@ -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.',
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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()));
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user