diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml index 9ffa9c1..e8a7642 100644 --- a/app/android/app/src/main/AndroidManifest.xml +++ b/app/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,8 @@ + + createState() => _HistoryScreenState(); +} + +class _HistoryScreenState extends State { + List? _entries; + + @override + void initState() { + super.initState(); + unawaited(_load()); + } + + Future _load() async { + final entries = await LogStorageService.instance.allEntriesNewestFirst(); + if (!mounted) return; + setState(() => _entries = entries); + } + + @override + Widget build(BuildContext context) { + final entries = _entries; + return Scaffold( + appBar: AppBar(title: const Text('History')), + body: entries == null + ? const Center(child: CircularProgressIndicator()) + : entries.isEmpty + ? const Center(child: Text('Nothing logged yet.')) + : ListView.builder( + itemCount: entries.length, + itemBuilder: (context, index) { + final entry = entries[index]; + return ListTile( + leading: _Thumbnail(imagePath: entry.imagePath), + title: Text(entry.desc), + subtitle: Text('${entry.time} • ${entry.source}'), + trailing: Text('${entry.kcal.toStringAsFixed(0)} kcal'), + ); + }, + ), + ); + } +} + +class _Thumbnail extends StatelessWidget { + const _Thumbnail({required this.imagePath}); + + final String? imagePath; + + @override + Widget build(BuildContext context) { + final path = imagePath; + if (path == null) { + return const SizedBox( + width: 40, + height: 40, + child: Icon(Icons.restaurant), + ); + } + return GestureDetector( + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => PhotoViewerScreen(path: path)), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Image.file( + File(path), + width: 40, + height: 40, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => const SizedBox( + width: 40, + height: 40, + child: Icon(Icons.broken_image), + ), + ), + ), + ); + } +} diff --git a/app/lib/screens/log_meal_screen.dart b/app/lib/screens/log_meal_screen.dart index 7a6f85e..6486740 100644 --- a/app/lib/screens/log_meal_screen.dart +++ b/app/lib/screens/log_meal_screen.dart @@ -7,11 +7,13 @@ import 'dart:async'; 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'; +import 'package:diet_guard_app/screens/history_screen.dart'; import 'package:diet_guard_app/screens/meal_builder_screen.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/widgets/autocomplete_suggestion_list.dart'; import 'package:diet_guard_app/widgets/macro_input_row.dart'; +import 'package:diet_guard_app/widgets/photo_attach_field.dart'; import 'package:diet_guard_app/widgets/slot_status_bar.dart'; import 'package:flutter/material.dart'; @@ -33,6 +35,7 @@ class _LogMealScreenState extends State { Set _loggedSlots = {}; String _source = 'manual'; String? _status; + String? _imagePath; @override void initState() { @@ -112,13 +115,21 @@ class _LogMealScreenState extends State { source: _source, ); final slot = currentSlot(DateTime.now()); - await LogStorageService.instance.logMeal(desc, nutrition, slot: slot); + await LogStorageService.instance.logMeal( + desc, + nutrition, + slot: slot, + imagePath: _imagePath, + ); final log = await LogStorageService.instance.readLog(); await FoodBankService.instance.rebuildAndPersist(log); if (!mounted) return; _descController.clear(); _macros.clear(); - setState(() => _source = 'manual'); + setState(() { + _source = 'manual'; + _imagePath = null; + }); await _refreshSlots(); if (!mounted) return; setState(() => _status = 'Logged "$desc".'); @@ -131,10 +142,27 @@ class _LogMealScreenState extends State { await _refreshSlots(); } + void _onOpenHistory() { + unawaited( + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const HistoryScreen()), + ), + ); + } + @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('Diet Guard')), + appBar: AppBar( + title: const Text('Diet Guard'), + actions: [ + IconButton( + icon: const Icon(Icons.history), + tooltip: 'History', + onPressed: _onOpenHistory, + ), + ], + ), body: SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( @@ -152,6 +180,11 @@ class _LogMealScreenState extends State { ), const SizedBox(height: 12), MacroInputRow(controllers: _macros), + const SizedBox(height: 12), + PhotoAttachField( + imagePath: _imagePath, + onChanged: (path) => setState(() => _imagePath = path), + ), const SizedBox(height: 16), Wrap( spacing: 8, diff --git a/app/lib/screens/meal_builder_screen.dart b/app/lib/screens/meal_builder_screen.dart index 08e4b44..703b60a 100644 --- a/app/lib/screens/meal_builder_screen.dart +++ b/app/lib/screens/meal_builder_screen.dart @@ -8,6 +8,7 @@ import 'package:diet_guard_app/models/slot.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/widgets/macro_input_row.dart'; +import 'package:diet_guard_app/widgets/photo_attach_field.dart'; import 'package:flutter/material.dart'; /// A screen for building and logging a multi-item meal as one composite @@ -26,6 +27,7 @@ class _MealBuilderScreenState extends State { final MacroControllers _macros = MacroControllers(); final List _items = []; String? _status; + String? _imagePath; @override void dispose() { @@ -77,6 +79,7 @@ class _MealBuilderScreenState extends State { total, slot: slot, components: components, + imagePath: _imagePath, ); final log = await LogStorageService.instance.readLog(); await FoodBankService.instance.rebuildAndPersist(log); @@ -118,6 +121,11 @@ class _MealBuilderScreenState extends State { ), const SizedBox(height: 8), MacroInputRow(controllers: _macros), + const SizedBox(height: 12), + PhotoAttachField( + imagePath: _imagePath, + onChanged: (path) => setState(() => _imagePath = path), + ), const SizedBox(height: 16), Wrap( spacing: 8, diff --git a/app/lib/screens/photo_viewer_screen.dart b/app/lib/screens/photo_viewer_screen.dart new file mode 100644 index 0000000..da99404 --- /dev/null +++ b/app/lib/screens/photo_viewer_screen.dart @@ -0,0 +1,39 @@ +/// Full-screen, pinch-to-zoom view of a locally attached meal photo. +library; + +import 'dart:io'; + +import 'package:flutter/material.dart'; + +/// Shows the image at [path] full-screen, with pinch-to-zoom and a back +/// button to dismiss. +class PhotoViewerScreen extends StatelessWidget { + /// Creates a [PhotoViewerScreen] for the photo at [path]. + const PhotoViewerScreen({required this.path, super.key}); + + /// Local filesystem path to the image to display. + final String path; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.black, + foregroundColor: Colors.white, + ), + body: Center( + child: InteractiveViewer( + child: Image.file( + File(path), + errorBuilder: (context, error, stackTrace) => const Icon( + Icons.broken_image, + color: Colors.white, + size: 64, + ), + ), + ), + ), + ); + } +} diff --git a/app/lib/services/log_storage_service.dart b/app/lib/services/log_storage_service.dart index 16bf8a7..fe60c8b 100644 --- a/app/lib/services/log_storage_service.dart +++ b/app/lib/services/log_storage_service.dart @@ -159,6 +159,24 @@ class LogStorageService { return entries.where((e) => !e.deleted).toList(); } + /// Returns every non-tombstoned entry across all days, newest first. + /// + /// Backs the history screen -- the only place that needs to see more than + /// "today". + Future> allEntriesNewestFirst() async { + final log = await readLog(); + final entries = [ + for (final dayEntries in log.values) + ...dayEntries.where((e) => !e.deleted), + ]..sort((a, b) { + final aTime = DateTime.tryParse(a.time); + final bTime = DateTime.tryParse(b.time); + if (aTime == null || bTime == null) return 0; + return bTime.compareTo(aTime); + }); + return entries; + } + /// Returns today's total calories, mirrors `_state.today_total_kcal`. Future todayTotalKcal() async { final entries = await todayEntries(); diff --git a/app/lib/services/photo_attach_service.dart b/app/lib/services/photo_attach_service.dart new file mode 100644 index 0000000..cee31b6 --- /dev/null +++ b/app/lib/services/photo_attach_service.dart @@ -0,0 +1,52 @@ +/// Picks a photo and copies it into permanent phone-local storage. +library; + +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:uuid/uuid.dart'; + +/// Wraps [ImagePicker] and persists the result under the app's documents +/// directory, so the returned path survives after the picker's own +/// (possibly cache-cleared) temp file is gone. +/// +/// Photos are phone-local only: per the sync plan (Milestone 3), a logged +/// entry's `imagePath` is stripped before push and never read from a pulled +/// remote copy, so no storage here needs to be sync-aware. +class PhotoAttachService { + PhotoAttachService._(this._testDir); + + static PhotoAttachService _instance = PhotoAttachService._(null); + + /// The singleton instance. + static PhotoAttachService get instance => _instance; + + final Directory? _testDir; + + /// Redirects where picked photos are copied to, so a test never touches + /// the real documents directory. Pass null to restore default behavior. + @visibleForTesting + static void resetForTesting({Directory? testDir}) { + _instance = PhotoAttachService._(testDir); + } + + /// Opens [source] (camera or gallery), and on a successful pick, copies + /// the image into `/images/.`. + /// + /// Returns the new permanent path, or null if the user cancelled the + /// picker. + Future pickAndStore(ImageSource source) async { + final picked = await ImagePicker().pickImage(source: source); + if (picked == null) return null; + final docsDir = _testDir ?? await getApplicationDocumentsDirectory(); + final imagesDir = Directory(p.join(docsDir.path, 'images')); + await imagesDir.create(recursive: true); + final ext = p.extension(picked.path); + final dest = p.join(imagesDir.path, '${const Uuid().v4()}$ext'); + await File(picked.path).copy(dest); + return dest; + } +} diff --git a/app/lib/widgets/photo_attach_field.dart b/app/lib/widgets/photo_attach_field.dart new file mode 100644 index 0000000..790f7f4 --- /dev/null +++ b/app/lib/widgets/photo_attach_field.dart @@ -0,0 +1,99 @@ +/// Shared attach/preview/remove control for a meal entry's optional photo. +library; + +import 'dart:io'; + +import 'package:diet_guard_app/screens/photo_viewer_screen.dart'; +import 'package:diet_guard_app/services/photo_attach_service.dart'; +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; + +/// Shows an "Attach photo" button when [imagePath] is null, or a tappable +/// thumbnail (opens [PhotoViewerScreen] full-screen) plus a "Remove photo" +/// action once one is set. +/// +/// Used identically by the single-item and composite-meal logging screens, +/// so the attach/preview/remove behavior only needs to be implemented once. +class PhotoAttachField extends StatelessWidget { + /// Creates a [PhotoAttachField]. + const PhotoAttachField({ + required this.imagePath, + required this.onChanged, + super.key, + }); + + /// The currently attached photo's local path, or null if none. + final String? imagePath; + + /// Called with the new path after a successful pick, or null after the + /// user removes the current photo. + final ValueChanged onChanged; + + Future _attach(BuildContext context) async { + final source = await showModalBottomSheet( + context: context, + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.photo_camera), + title: const Text('Take a photo'), + onTap: () => + Navigator.of(sheetContext).pop(ImageSource.camera), + ), + ListTile( + leading: const Icon(Icons.photo_library), + title: const Text('Choose from gallery'), + onTap: () => + Navigator.of(sheetContext).pop(ImageSource.gallery), + ), + ], + ), + ), + ); + if (source == null) return; + final path = await PhotoAttachService.instance.pickAndStore(source); + if (path != null) onChanged(path); + } + + @override + Widget build(BuildContext context) { + final path = imagePath; + if (path == null) { + return OutlinedButton.icon( + onPressed: () => _attach(context), + icon: const Icon(Icons.add_a_photo), + label: const Text('Attach photo'), + ); + } + return Row( + children: [ + GestureDetector( + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => PhotoViewerScreen(path: path)), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.file( + File(path), + width: 64, + height: 64, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => const SizedBox( + width: 64, + height: 64, + child: Icon(Icons.broken_image), + ), + ), + ), + ), + const SizedBox(width: 8), + TextButton( + onPressed: () => onChanged(null), + child: const Text('Remove photo'), + ), + ], + ); + } +} diff --git a/app/pubspec.lock b/app/pubspec.lock index eb92e65..13c414c 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -57,6 +57,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" crypto: dependency: transitive description: @@ -81,6 +89,38 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" fixnum: dependency: transitive description: @@ -94,11 +134,24 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" + url: "https://pub.dev" + source: hosted + version: "2.0.35" flutter_test: dependency: "direct dev" description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" hooks: dependency: transitive description: @@ -107,6 +160,86 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "91c025426c2881c551100bce834e201c835a170151545f58d17da5180ca7d9ac" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "6f3a1995eafb000333174fae92202622033b0ee7fd917a6cd3730295264df84a" + url: "https://pub.dev" + source: hosted + version: "0.8.13+19" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + url: "https://pub.dev" + source: hosted + version: "0.8.13+6" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: "direct dev" + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" jni: dependency: transitive description: @@ -179,6 +312,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.18.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" objective_c: dependency: transitive description: @@ -376,6 +517,14 @@ packages: url: "https://pub.dev" source: hosted version: "15.2.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" xdg_directories: dependency: transitive description: @@ -394,4 +543,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.12.2 <4.0.0" - flutter: ">=3.38.4" + flutter: ">=3.44.0" diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 7584d1f..5f5d6ac 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -10,6 +10,7 @@ environment: dependencies: flutter: sdk: flutter + image_picker: ^1.1.2 path: ^1.9.1 path_provider: ^2.1.5 uuid: ^4.5.3 @@ -17,6 +18,7 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter + image_picker_platform_interface: ^2.10.0 very_good_analysis: ^10.2.0 flutter: diff --git a/app/test/screens/history_screen_test.dart b/app/test/screens/history_screen_test.dart new file mode 100644 index 0000000..f73eefe --- /dev/null +++ b/app/test/screens/history_screen_test.dart @@ -0,0 +1,131 @@ +import 'dart:io'; + +import 'package:diet_guard_app/models/food_entry.dart'; +import 'package:diet_guard_app/screens/history_screen.dart'; +import 'package:diet_guard_app/screens/photo_viewer_screen.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('diet_guard_history_'); + LogStorageService.resetForTesting(testDir: tempDir); + }); + + tearDown(() async { + LogStorageService.resetForTesting(); + await tempDir.delete(recursive: true); + }); + + // HistoryScreen loads via a fire-and-forget Future in initState that + // Flutter's frame scheduler does not track -- see log_meal_screen_test.dart + // for the same issue. Every test therefore runs inside runAsync() with a + // short real delay before settling. + Future settle(WidgetTester tester) async { + await Future.delayed(const Duration(milliseconds: 200)); + await tester.pumpAndSettle(); + } + + testWidgets('shows a message when nothing has been logged', (tester) async { + await tester.runAsync(() async { + await tester.pumpWidget(const MaterialApp(home: HistoryScreen())); + await settle(tester); + + expect(find.text('Nothing logged yet.'), findsOneWidget); + }); + }); + + testWidgets('lists logged entries newest first, excluding tombstones', + (tester) async { + await tester.runAsync(() async { + await LogStorageService.instance.writeLog({ + '2026-06-01': [ + const FoodEntry( + id: 'old', + time: '2026-06-01T08:00:00+02:00', + desc: 'old breakfast', + grams: 100, + kcal: 100, + proteinG: 5, + carbsG: 10, + fatG: 2, + source: 'manual', + ), + ], + '2026-06-22': [ + const FoodEntry( + id: 'new', + time: '2026-06-22T20:00:00+02:00', + desc: 'new dinner', + grams: 100, + kcal: 200, + proteinG: 10, + carbsG: 20, + fatG: 4, + source: 'manual', + ), + const FoodEntry( + id: 'gone', + time: '2026-06-22T12:00:00+02:00', + desc: 'undone lunch', + grams: 100, + kcal: 300, + proteinG: 1, + carbsG: 1, + fatG: 1, + source: 'manual', + deleted: true, + ), + ], + }); + + await tester.pumpWidget(const MaterialApp(home: HistoryScreen())); + await settle(tester); + + expect(find.text('new dinner'), findsOneWidget); + expect(find.text('old breakfast'), findsOneWidget); + expect(find.text('undone lunch'), findsNothing); + + final tiles = tester + .widgetList(find.byType(ListTile)) + .toList(); + expect((tiles[0].title! as Text).data, 'new dinner'); + expect((tiles[1].title! as Text).data, 'old breakfast'); + }); + }); + + testWidgets('tapping a thumbnail opens the full-screen photo viewer', + (tester) async { + await tester.runAsync(() async { + final imageFile = File('${tempDir.path}/photo.png') + ..writeAsBytesSync([1, 2, 3]); + await LogStorageService.instance.writeLog({ + '2026-06-22': [ + FoodEntry( + id: 'with-photo', + time: '2026-06-22T20:00:00+02:00', + desc: 'dinner with a photo', + grams: 100, + kcal: 200, + proteinG: 10, + carbsG: 20, + fatG: 4, + source: 'manual', + imagePath: imageFile.path, + ), + ], + }); + + await tester.pumpWidget(const MaterialApp(home: HistoryScreen())); + await settle(tester); + + await tester.tap(find.byType(Image)); + await settle(tester); + + expect(find.byType(PhotoViewerScreen), findsOneWidget); + }); + }); +} diff --git a/app/test/screens/log_meal_screen_test.dart b/app/test/screens/log_meal_screen_test.dart index bb433ef..a6b95d7 100644 --- a/app/test/screens/log_meal_screen_test.dart +++ b/app/test/screens/log_meal_screen_test.dart @@ -2,23 +2,118 @@ import 'dart:io'; import 'package:diet_guard_app/models/food_entry.dart'; import 'package:diet_guard_app/screens/log_meal_screen.dart'; +import 'package:diet_guard_app/screens/history_screen.dart'; +import 'package:diet_guard_app/screens/photo_viewer_screen.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'; 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'; + +/// Returns a fixed [XFile] without touching any real platform channel. +class _FakeImagePickerPlatform extends ImagePickerPlatform { + _FakeImagePickerPlatform(this._result); + + final XFile? _result; + + @override + Future getImageFromSource({ + required ImageSource source, + ImagePickerOptions options = const ImagePickerOptions(), + }) async => _result; +} + +/// A minimal valid 1x1 transparent PNG, so the thumbnail preview can decode +/// it as a real image instead of throwing on bogus bytes. +const List _onePixelPng = [ + 0x89, + 0x50, + 0x4E, + 0x47, + 0x0D, + 0x0A, + 0x1A, + 0x0A, + 0x00, + 0x00, + 0x00, + 0x0D, + 0x49, + 0x48, + 0x44, + 0x52, + 0x00, + 0x00, + 0x00, + 0x01, + 0x00, + 0x00, + 0x00, + 0x01, + 0x08, + 0x06, + 0x00, + 0x00, + 0x00, + 0x1F, + 0x15, + 0xC4, + 0x89, + 0x00, + 0x00, + 0x00, + 0x0D, + 0x49, + 0x44, + 0x41, + 0x54, + 0x78, + 0x9C, + 0x62, + 0x00, + 0x01, + 0x00, + 0x00, + 0x05, + 0x00, + 0x01, + 0x0D, + 0x0A, + 0x2D, + 0xB4, + 0x00, + 0x00, + 0x00, + 0x00, + 0x49, + 0x45, + 0x4E, + 0x44, + 0xAE, + 0x42, + 0x60, + 0x82, +]; void main() { late Directory tempDir; + late ImagePickerPlatform originalImagePickerPlatform; setUp(() async { tempDir = await Directory.systemTemp.createTemp('diet_guard_screen_'); LogStorageService.resetForTesting(testDir: tempDir); FoodBankService.resetForTesting(testDir: tempDir); + PhotoAttachService.resetForTesting(testDir: tempDir); + originalImagePickerPlatform = ImagePickerPlatform.instance; }); tearDown(() async { LogStorageService.resetForTesting(); FoodBankService.resetForTesting(); + PhotoAttachService.resetForTesting(); + ImagePickerPlatform.instance = originalImagePickerPlatform; await tempDir.delete(recursive: true); }); @@ -36,8 +131,21 @@ void main() { await tester.pumpAndSettle(); } - testWidgets('logging a manually-typed meal persists it as source manual', - (tester) async { + testWidgets('the history icon navigates to HistoryScreen', (tester) async { + await tester.runAsync(() async { + await tester.pumpWidget(const MaterialApp(home: LogMealScreen())); + await settle(tester); + + await tester.tap(find.byIcon(Icons.history)); + await settle(tester); + + expect(find.byType(HistoryScreen), findsOneWidget); + }); + }); + + testWidgets('logging a manually-typed meal persists it as source manual', ( + tester, + ) async { await tester.runAsync(() async { await tester.pumpWidget(const MaterialApp(home: LogMealScreen())); await settle(tester); @@ -50,6 +158,7 @@ void main() { await tester.enterText(find.byType(TextField).at(5), '3'); await settle(tester); + await tester.ensureVisible(logMealButton); await tester.tap(logMealButton); await settle(tester); @@ -65,6 +174,7 @@ void main() { await tester.pumpWidget(const MaterialApp(home: LogMealScreen())); await settle(tester); + await tester.ensureVisible(logMealButton); await tester.tap(logMealButton); await settle(tester); @@ -90,11 +200,11 @@ void main() { await tester.enterText(find.byType(TextField).at(6), '150'); await settle(tester); + await tester.ensureVisible(logMealButton); await tester.tap(logMealButton); await settle(tester); - final entry = - (await LogStorageService.instance.todayEntries()).single; + final entry = (await LogStorageService.instance.todayEntries()).single; expect(entry.kcal, 300); expect(entry.proteinG, 15); expect(entry.carbsG, 30); @@ -130,6 +240,7 @@ void main() { // The empty-query suggestion list shows the only banked food. await tester.tap(find.text('seeded food')); await settle(tester); + await tester.ensureVisible(logMealButton); await tester.tap(logMealButton); await settle(tester); @@ -142,6 +253,7 @@ void main() { await settle(tester); await tester.enterText(find.byType(TextField).at(1), '999'); await settle(tester); + await tester.ensureVisible(logMealButton); await tester.tap(logMealButton); await settle(tester); @@ -152,4 +264,85 @@ void main() { }); }, ); + + testWidgets( + 'attaching a photo persists its path on the logged entry, and removing ' + 'it before logging clears it again', + (tester) async { + await tester.runAsync(() async { + // A real (1x1, transparent) PNG, not an arbitrary byte sequence -- + // the thumbnail preview decodes this file as an actual image, and a + // bogus payload throws inside the image codec rather than failing + // cleanly. + final source = File('${tempDir.path}/source.jpg') + ..writeAsBytesSync(_onePixelPng); + ImagePickerPlatform.instance = _FakeImagePickerPlatform( + XFile(source.path), + ); + + await tester.pumpWidget(const MaterialApp(home: LogMealScreen())); + await settle(tester); + + await tester.enterText(find.byType(TextField).at(0), 'snack'); + await settle(tester); + + await tester.tap(find.text('Attach photo')); + await settle(tester); + await tester.tap(find.text('Choose from gallery')); + await settle(tester); + + expect(find.text('Remove photo'), findsOneWidget); + + await tester.ensureVisible(logMealButton); + await tester.tap(logMealButton); + await settle(tester); + + final entry = (await LogStorageService.instance.todayEntries()).single; + expect(entry.imagePath, isNotNull); + expect(entry.imagePath, startsWith('${tempDir.path}/images/')); + expect(File(entry.imagePath!).readAsBytesSync(), _onePixelPng); + + await tester.enterText(find.byType(TextField).at(0), 'snack two'); + await settle(tester); + await tester.tap(find.text('Attach photo')); + await settle(tester); + await tester.tap(find.text('Choose from gallery')); + await settle(tester); + await tester.tap(find.text('Remove photo')); + await settle(tester); + await tester.ensureVisible(logMealButton); + await tester.tap(logMealButton); + await settle(tester); + + final secondEntry = + (await LogStorageService.instance.todayEntries()).last; + expect(secondEntry.imagePath, isNull); + }); + }, + ); + + testWidgets('tapping the attached-photo thumbnail opens the full viewer', ( + tester, + ) async { + await tester.runAsync(() async { + final source = File('${tempDir.path}/source.jpg') + ..writeAsBytesSync(_onePixelPng); + ImagePickerPlatform.instance = _FakeImagePickerPlatform( + XFile(source.path), + ); + + await tester.pumpWidget(const MaterialApp(home: LogMealScreen())); + await settle(tester); + + await tester.tap(find.text('Attach photo')); + await settle(tester); + await tester.tap(find.text('Choose from gallery')); + await settle(tester); + + await tester.tap(find.byType(Image)); + await settle(tester); + + expect(find.byType(PhotoViewerScreen), findsOneWidget); + }); + }); } diff --git a/app/test/screens/meal_builder_screen_test.dart b/app/test/screens/meal_builder_screen_test.dart index 91caa78..bea4d46 100644 --- a/app/test/screens/meal_builder_screen_test.dart +++ b/app/test/screens/meal_builder_screen_test.dart @@ -3,21 +3,42 @@ import 'dart:io'; import 'package:diet_guard_app/screens/meal_builder_screen.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'; 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'; + +/// Returns a fixed [XFile] without touching any real platform channel. +class _FakeImagePickerPlatform extends ImagePickerPlatform { + _FakeImagePickerPlatform(this._result); + + final XFile? _result; + + @override + Future getImageFromSource({ + required ImageSource source, + ImagePickerOptions options = const ImagePickerOptions(), + }) async => _result; +} void main() { late Directory tempDir; + late ImagePickerPlatform originalImagePickerPlatform; setUp(() async { tempDir = await Directory.systemTemp.createTemp('diet_guard_builder_'); LogStorageService.resetForTesting(testDir: tempDir); FoodBankService.resetForTesting(testDir: tempDir); + PhotoAttachService.resetForTesting(testDir: tempDir); + originalImagePickerPlatform = ImagePickerPlatform.instance; }); tearDown(() async { LogStorageService.resetForTesting(); FoodBankService.resetForTesting(); + PhotoAttachService.resetForTesting(); + ImagePickerPlatform.instance = originalImagePickerPlatform; await tempDir.delete(recursive: true); }); @@ -96,4 +117,44 @@ void main() { }); }, ); + + testWidgets( + 'attaching a photo to a composite meal persists its path on the logged ' + 'entry', + (tester) async { + await tester.runAsync(() async { + final source = File('${tempDir.path}/source.jpg') + ..writeAsBytesSync([1, 2, 3]); + ImagePickerPlatform.instance = _FakeImagePickerPlatform( + XFile(source.path), + ); + + await tester.pumpWidget( + const MaterialApp(home: MealBuilderScreen()), + ); + await settle(tester); + + await tester.enterText(find.byType(TextField).at(1), 'soup'); + await tester.enterText(find.byType(TextField).at(2), '120'); + await settle(tester); + await tester.tap(addItemButton); + await settle(tester); + + await tester.ensureVisible(find.text('Attach photo')); + await tester.tap(find.text('Attach photo')); + await settle(tester); + await tester.tap(find.text('Choose from gallery')); + await settle(tester); + + await tester.ensureVisible(logMealButton); + await tester.tap(logMealButton); + await settle(tester); + + final entry = + (await LogStorageService.instance.todayEntries()).single; + expect(entry.imagePath, isNotNull); + expect(entry.imagePath, startsWith('${tempDir.path}/images/')); + }); + }, + ); } diff --git a/app/test/services/log_storage_service_test.dart b/app/test/services/log_storage_service_test.dart index 8fe63ef..b9233bb 100644 --- a/app/test/services/log_storage_service_test.dart +++ b/app/test/services/log_storage_service_test.dart @@ -174,4 +174,58 @@ void main() { expect(await LogStorageService.instance.loggedSlotsToday(), {8}); }); }); + + group('allEntriesNewestFirst', () { + const oldest = FoodEntry( + id: 'oldest', + time: '2026-06-01T08:00:00+02:00', + desc: 'oldest', + grams: 100, + kcal: 100, + proteinG: 5, + carbsG: 10, + fatG: 2, + source: 'manual', + ); + const newest = FoodEntry( + id: 'newest', + time: '2026-06-22T20:00:00+02:00', + desc: 'newest', + grams: 100, + kcal: 200, + proteinG: 10, + carbsG: 20, + fatG: 4, + source: 'manual', + ); + const tombstoned = FoodEntry( + id: 'gone', + time: '2026-06-15T12:00:00+02:00', + desc: 'undone', + grams: 100, + kcal: 300, + proteinG: 1, + carbsG: 1, + fatG: 1, + source: 'manual', + deleted: true, + ); + + test('sorts entries across days newest-first and drops tombstones', + () async { + await LogStorageService.instance.writeLog({ + '2026-06-01': [oldest], + '2026-06-15': [tombstoned], + '2026-06-22': [newest], + }); + + final result = await LogStorageService.instance.allEntriesNewestFirst(); + + expect(result.map((e) => e.id), ['newest', 'oldest']); + }); + + test('returns empty for an empty log', () async { + expect(await LogStorageService.instance.allEntriesNewestFirst(), isEmpty); + }); + }); } diff --git a/app/test/services/photo_attach_service_test.dart b/app/test/services/photo_attach_service_test.dart new file mode 100644 index 0000000..fe66025 --- /dev/null +++ b/app/test/services/photo_attach_service_test.dart @@ -0,0 +1,64 @@ +import 'dart:io'; + +import 'package:diet_guard_app/services/photo_attach_service.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'; + +/// Returns a fixed [XFile] (or null, to simulate a cancelled picker) without +/// touching any real platform channel. +class _FakeImagePickerPlatform extends ImagePickerPlatform { + _FakeImagePickerPlatform(this._result); + + final XFile? _result; + + @override + Future getImageFromSource({ + required ImageSource source, + ImagePickerOptions options = const ImagePickerOptions(), + }) async => _result; +} + +void main() { + late Directory tempDir; + late ImagePickerPlatform originalPlatform; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('diet_guard_photo_'); + originalPlatform = ImagePickerPlatform.instance; + PhotoAttachService.resetForTesting(testDir: tempDir); + }); + + tearDown(() async { + ImagePickerPlatform.instance = originalPlatform; + PhotoAttachService.resetForTesting(); + await tempDir.delete(recursive: true); + }); + + test('copies the picked file into /images with a new name', () async { + final source = File('${tempDir.path}/source.jpg') + ..writeAsBytesSync([1, 2, 3, 4]); + ImagePickerPlatform.instance = _FakeImagePickerPlatform( + XFile(source.path), + ); + + final result = await PhotoAttachService.instance.pickAndStore( + ImageSource.gallery, + ); + + expect(result, isNotNull); + expect(result, startsWith('${tempDir.path}/images/')); + expect(result, endsWith('.jpg')); + expect(File(result!).readAsBytesSync(), [1, 2, 3, 4]); + }); + + test('returns null when the picker is cancelled', () async { + ImagePickerPlatform.instance = _FakeImagePickerPlatform(null); + + final result = await PhotoAttachService.instance.pickAndStore( + ImageSource.camera, + ); + + expect(result, isNull); + }); +}