mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-07-04 11:43:07 +02:00
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:
parent
ee5a7660cb
commit
feef5984f8
@ -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}"
|
||||
|
||||
100
app/lib/screens/history_screen.dart
Normal file
100
app/lib/screens/history_screen.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
39
app/lib/screens/photo_viewer_screen.dart
Normal file
39
app/lib/screens/photo_viewer_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
|
||||
52
app/lib/services/photo_attach_service.dart
Normal file
52
app/lib/services/photo_attach_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
99
app/lib/widgets/photo_attach_field.dart
Normal file
99
app/lib/widgets/photo_attach_field.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
151
app/pubspec.lock
151
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"
|
||||
|
||||
@ -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:
|
||||
|
||||
131
app/test/screens/history_screen_test.dart
Normal file
131
app/test/screens/history_screen_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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/'));
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
64
app/test/services/photo_attach_service_test.dart
Normal file
64
app/test/services/photo_attach_service_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user