Add photo attach, full-size viewer, and a minimal history screen

Milestone 2 of the diet-app-as-wise-balloon plan, plus feedback from
manually testing it on-device:

- PhotoAttachService wraps image_picker and copies the picked photo into
  <app documents>/images/<uuid>.<ext>, so the file survives after the
  picker's own (possibly cache-cleared) temp copy is gone. Phone-local
  only, per the sync plan: imagePath is never synced.
- PhotoAttachField is a shared attach/preview/remove widget, used
  identically by both the single-item and composite-meal logging
  screens, so logging a multi-item meal can now carry a photo too.
- PhotoViewerScreen gives a full-screen, pinch-to-zoom view of an
  attached photo -- the 64x64 inline thumbnail was too small to
  actually check the photo.
- HistoryScreen lists every logged entry across all days, newest first,
  with a thumbnail when one is attached. There was previously no way to
  confirm what got logged (or whether a photo actually attached) short
  of inspecting food_log.json directly.

Verified on a physical device (BL9000): built, installed, and the user
confirmed the photo-attach flow logs a real entry with a real photo,
visible afterward in the new history list. 88 Flutter tests passing,
`flutter analyze` clean against very_good_analysis.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FU3f5KQ1GHXsbbSecfVEyF
This commit is contained in:
Krzysztof kuhy Rudnicki 2026-06-22 18:57:58 +02:00
parent ee5a7660cb
commit feef5984f8
15 changed files with 1015 additions and 8 deletions

View File

@ -1,4 +1,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- image_picker's camera source delegates to the device's camera app via
intent, but declaring this avoids picker failures on OEMs that check
for it before honoring the intent. -->
<uses-permission android:name="android.permission.CAMERA"/>
<application
android:label="diet_guard_app"
android:name="${applicationName}"

View File

@ -0,0 +1,100 @@
/// Read-only list of every logged meal, newest first.
library;
import 'dart:async';
import 'dart:io';
import 'package:diet_guard_app/models/food_entry.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';
/// Shows every non-deleted logged entry across all days, so the user can
/// confirm what was actually logged (including whether a photo attached).
///
/// Deliberately minimal: no editing, filtering, or pagination -- just
/// enough to answer "did this get logged, and with what photo?"
class HistoryScreen extends StatefulWidget {
/// Creates a [HistoryScreen].
const HistoryScreen({super.key});
@override
State<HistoryScreen> createState() => _HistoryScreenState();
}
class _HistoryScreenState extends State<HistoryScreen> {
List<FoodEntry>? _entries;
@override
void initState() {
super.initState();
unawaited(_load());
}
Future<void> _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<void>(
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),
),
),
),
);
}
}

View File

@ -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<LogMealScreen> {
Set<int> _loggedSlots = {};
String _source = 'manual';
String? _status;
String? _imagePath;
@override
void initState() {
@ -112,13 +115,21 @@ class _LogMealScreenState extends State<LogMealScreen> {
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<LogMealScreen> {
await _refreshSlots();
}
void _onOpenHistory() {
unawaited(
Navigator.of(context).push<void>(
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<LogMealScreen> {
),
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,

View File

@ -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<MealBuilderScreen> {
final MacroControllers _macros = MacroControllers();
final List<MealItem> _items = [];
String? _status;
String? _imagePath;
@override
void dispose() {
@ -77,6 +79,7 @@ class _MealBuilderScreenState extends State<MealBuilderScreen> {
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<MealBuilderScreen> {
),
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,

View File

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

View File

@ -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<List<FoodEntry>> 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<double> todayTotalKcal() async {
final entries = await todayEntries();

View File

@ -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 `<app documents>/images/<uuid>.<ext>`.
///
/// Returns the new permanent path, or null if the user cancelled the
/// picker.
Future<String?> 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;
}
}

View File

@ -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<String?> onChanged;
Future<void> _attach(BuildContext context) async {
final source = await showModalBottomSheet<ImageSource>(
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<void>(
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'),
),
],
);
}
}

View File

@ -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"

View File

@ -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:

View File

@ -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<void> settle(WidgetTester tester) async {
await Future<void>.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<ListTile>(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);
});
});
}

View File

@ -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<XFile?> 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<int> _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);
});
});
}

View File

@ -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<XFile?> 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/'));
});
},
);
}

View File

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

View File

@ -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<XFile?> 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 <documents>/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);
});
}