mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-07-04 15:23:16 +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">
|
<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
|
<application
|
||||||
android:label="diet_guard_app"
|
android:label="diet_guard_app"
|
||||||
android:name="${applicationName}"
|
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/food_suggestion.dart';
|
||||||
import 'package:diet_guard_app/models/nutrition.dart';
|
import 'package:diet_guard_app/models/nutrition.dart';
|
||||||
import 'package:diet_guard_app/models/slot.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/screens/meal_builder_screen.dart';
|
||||||
import 'package:diet_guard_app/services/foodbank_service.dart';
|
import 'package:diet_guard_app/services/foodbank_service.dart';
|
||||||
import 'package:diet_guard_app/services/log_storage_service.dart';
|
import 'package:diet_guard_app/services/log_storage_service.dart';
|
||||||
import 'package:diet_guard_app/widgets/autocomplete_suggestion_list.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/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:diet_guard_app/widgets/slot_status_bar.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
@ -33,6 +35,7 @@ class _LogMealScreenState extends State<LogMealScreen> {
|
|||||||
Set<int> _loggedSlots = {};
|
Set<int> _loggedSlots = {};
|
||||||
String _source = 'manual';
|
String _source = 'manual';
|
||||||
String? _status;
|
String? _status;
|
||||||
|
String? _imagePath;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@ -112,13 +115,21 @@ class _LogMealScreenState extends State<LogMealScreen> {
|
|||||||
source: _source,
|
source: _source,
|
||||||
);
|
);
|
||||||
final slot = currentSlot(DateTime.now());
|
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();
|
final log = await LogStorageService.instance.readLog();
|
||||||
await FoodBankService.instance.rebuildAndPersist(log);
|
await FoodBankService.instance.rebuildAndPersist(log);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_descController.clear();
|
_descController.clear();
|
||||||
_macros.clear();
|
_macros.clear();
|
||||||
setState(() => _source = 'manual');
|
setState(() {
|
||||||
|
_source = 'manual';
|
||||||
|
_imagePath = null;
|
||||||
|
});
|
||||||
await _refreshSlots();
|
await _refreshSlots();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _status = 'Logged "$desc".');
|
setState(() => _status = 'Logged "$desc".');
|
||||||
@ -131,10 +142,27 @@ class _LogMealScreenState extends State<LogMealScreen> {
|
|||||||
await _refreshSlots();
|
await _refreshSlots();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onOpenHistory() {
|
||||||
|
unawaited(
|
||||||
|
Navigator.of(context).push<void>(
|
||||||
|
MaterialPageRoute(builder: (_) => const HistoryScreen()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
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(
|
body: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
@ -152,6 +180,11 @@ class _LogMealScreenState extends State<LogMealScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
MacroInputRow(controllers: _macros),
|
MacroInputRow(controllers: _macros),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
PhotoAttachField(
|
||||||
|
imagePath: _imagePath,
|
||||||
|
onChanged: (path) => setState(() => _imagePath = path),
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 8,
|
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/foodbank_service.dart';
|
||||||
import 'package:diet_guard_app/services/log_storage_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/macro_input_row.dart';
|
||||||
|
import 'package:diet_guard_app/widgets/photo_attach_field.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// A screen for building and logging a multi-item meal as one composite
|
/// 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 MacroControllers _macros = MacroControllers();
|
||||||
final List<MealItem> _items = [];
|
final List<MealItem> _items = [];
|
||||||
String? _status;
|
String? _status;
|
||||||
|
String? _imagePath;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@ -77,6 +79,7 @@ class _MealBuilderScreenState extends State<MealBuilderScreen> {
|
|||||||
total,
|
total,
|
||||||
slot: slot,
|
slot: slot,
|
||||||
components: components,
|
components: components,
|
||||||
|
imagePath: _imagePath,
|
||||||
);
|
);
|
||||||
final log = await LogStorageService.instance.readLog();
|
final log = await LogStorageService.instance.readLog();
|
||||||
await FoodBankService.instance.rebuildAndPersist(log);
|
await FoodBankService.instance.rebuildAndPersist(log);
|
||||||
@ -118,6 +121,11 @@ class _MealBuilderScreenState extends State<MealBuilderScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
MacroInputRow(controllers: _macros),
|
MacroInputRow(controllers: _macros),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
PhotoAttachField(
|
||||||
|
imagePath: _imagePath,
|
||||||
|
onChanged: (path) => setState(() => _imagePath = path),
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 8,
|
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();
|
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`.
|
/// Returns today's total calories, mirrors `_state.today_total_kcal`.
|
||||||
Future<double> todayTotalKcal() async {
|
Future<double> todayTotalKcal() async {
|
||||||
final entries = await todayEntries();
|
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"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.1"
|
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:
|
crypto:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -81,6 +89,38 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
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:
|
fixnum:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -94,11 +134,24 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
flutter_web_plugins:
|
||||||
|
dependency: transitive
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
hooks:
|
hooks:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -107,6 +160,86 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.2"
|
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:
|
jni:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -179,6 +312,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.18.0"
|
version: "1.18.0"
|
||||||
|
mime:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: mime
|
||||||
|
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.0"
|
||||||
objective_c:
|
objective_c:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -376,6 +517,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "15.2.0"
|
version: "15.2.0"
|
||||||
|
web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: web
|
||||||
|
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.1"
|
||||||
xdg_directories:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -394,4 +543,4 @@ packages:
|
|||||||
version: "3.1.3"
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.12.2 <4.0.0"
|
dart: ">=3.12.2 <4.0.0"
|
||||||
flutter: ">=3.38.4"
|
flutter: ">=3.44.0"
|
||||||
|
|||||||
@ -10,6 +10,7 @@ environment:
|
|||||||
dependencies:
|
dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
image_picker: ^1.1.2
|
||||||
path: ^1.9.1
|
path: ^1.9.1
|
||||||
path_provider: ^2.1.5
|
path_provider: ^2.1.5
|
||||||
uuid: ^4.5.3
|
uuid: ^4.5.3
|
||||||
@ -17,6 +18,7 @@ dependencies:
|
|||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
image_picker_platform_interface: ^2.10.0
|
||||||
very_good_analysis: ^10.2.0
|
very_good_analysis: ^10.2.0
|
||||||
|
|
||||||
flutter:
|
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/models/food_entry.dart';
|
||||||
import 'package:diet_guard_app/screens/log_meal_screen.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/foodbank_service.dart';
|
||||||
import 'package:diet_guard_app/services/log_storage_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/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.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() {
|
void main() {
|
||||||
late Directory tempDir;
|
late Directory tempDir;
|
||||||
|
late ImagePickerPlatform originalImagePickerPlatform;
|
||||||
|
|
||||||
setUp(() async {
|
setUp(() async {
|
||||||
tempDir = await Directory.systemTemp.createTemp('diet_guard_screen_');
|
tempDir = await Directory.systemTemp.createTemp('diet_guard_screen_');
|
||||||
LogStorageService.resetForTesting(testDir: tempDir);
|
LogStorageService.resetForTesting(testDir: tempDir);
|
||||||
FoodBankService.resetForTesting(testDir: tempDir);
|
FoodBankService.resetForTesting(testDir: tempDir);
|
||||||
|
PhotoAttachService.resetForTesting(testDir: tempDir);
|
||||||
|
originalImagePickerPlatform = ImagePickerPlatform.instance;
|
||||||
});
|
});
|
||||||
|
|
||||||
tearDown(() async {
|
tearDown(() async {
|
||||||
LogStorageService.resetForTesting();
|
LogStorageService.resetForTesting();
|
||||||
FoodBankService.resetForTesting();
|
FoodBankService.resetForTesting();
|
||||||
|
PhotoAttachService.resetForTesting();
|
||||||
|
ImagePickerPlatform.instance = originalImagePickerPlatform;
|
||||||
await tempDir.delete(recursive: true);
|
await tempDir.delete(recursive: true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -36,8 +131,21 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
}
|
}
|
||||||
|
|
||||||
testWidgets('logging a manually-typed meal persists it as source manual',
|
testWidgets('the history icon navigates to HistoryScreen', (tester) async {
|
||||||
(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.runAsync(() async {
|
||||||
await tester.pumpWidget(const MaterialApp(home: LogMealScreen()));
|
await tester.pumpWidget(const MaterialApp(home: LogMealScreen()));
|
||||||
await settle(tester);
|
await settle(tester);
|
||||||
@ -50,6 +158,7 @@ void main() {
|
|||||||
await tester.enterText(find.byType(TextField).at(5), '3');
|
await tester.enterText(find.byType(TextField).at(5), '3');
|
||||||
await settle(tester);
|
await settle(tester);
|
||||||
|
|
||||||
|
await tester.ensureVisible(logMealButton);
|
||||||
await tester.tap(logMealButton);
|
await tester.tap(logMealButton);
|
||||||
await settle(tester);
|
await settle(tester);
|
||||||
|
|
||||||
@ -65,6 +174,7 @@ void main() {
|
|||||||
await tester.pumpWidget(const MaterialApp(home: LogMealScreen()));
|
await tester.pumpWidget(const MaterialApp(home: LogMealScreen()));
|
||||||
await settle(tester);
|
await settle(tester);
|
||||||
|
|
||||||
|
await tester.ensureVisible(logMealButton);
|
||||||
await tester.tap(logMealButton);
|
await tester.tap(logMealButton);
|
||||||
await settle(tester);
|
await settle(tester);
|
||||||
|
|
||||||
@ -90,11 +200,11 @@ void main() {
|
|||||||
await tester.enterText(find.byType(TextField).at(6), '150');
|
await tester.enterText(find.byType(TextField).at(6), '150');
|
||||||
await settle(tester);
|
await settle(tester);
|
||||||
|
|
||||||
|
await tester.ensureVisible(logMealButton);
|
||||||
await tester.tap(logMealButton);
|
await tester.tap(logMealButton);
|
||||||
await settle(tester);
|
await settle(tester);
|
||||||
|
|
||||||
final entry =
|
final entry = (await LogStorageService.instance.todayEntries()).single;
|
||||||
(await LogStorageService.instance.todayEntries()).single;
|
|
||||||
expect(entry.kcal, 300);
|
expect(entry.kcal, 300);
|
||||||
expect(entry.proteinG, 15);
|
expect(entry.proteinG, 15);
|
||||||
expect(entry.carbsG, 30);
|
expect(entry.carbsG, 30);
|
||||||
@ -130,6 +240,7 @@ void main() {
|
|||||||
// The empty-query suggestion list shows the only banked food.
|
// The empty-query suggestion list shows the only banked food.
|
||||||
await tester.tap(find.text('seeded food'));
|
await tester.tap(find.text('seeded food'));
|
||||||
await settle(tester);
|
await settle(tester);
|
||||||
|
await tester.ensureVisible(logMealButton);
|
||||||
await tester.tap(logMealButton);
|
await tester.tap(logMealButton);
|
||||||
await settle(tester);
|
await settle(tester);
|
||||||
|
|
||||||
@ -142,6 +253,7 @@ void main() {
|
|||||||
await settle(tester);
|
await settle(tester);
|
||||||
await tester.enterText(find.byType(TextField).at(1), '999');
|
await tester.enterText(find.byType(TextField).at(1), '999');
|
||||||
await settle(tester);
|
await settle(tester);
|
||||||
|
await tester.ensureVisible(logMealButton);
|
||||||
await tester.tap(logMealButton);
|
await tester.tap(logMealButton);
|
||||||
await settle(tester);
|
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/screens/meal_builder_screen.dart';
|
||||||
import 'package:diet_guard_app/services/foodbank_service.dart';
|
import 'package:diet_guard_app/services/foodbank_service.dart';
|
||||||
import 'package:diet_guard_app/services/log_storage_service.dart';
|
import 'package:diet_guard_app/services/log_storage_service.dart';
|
||||||
|
import 'package:diet_guard_app/services/photo_attach_service.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.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() {
|
void main() {
|
||||||
late Directory tempDir;
|
late Directory tempDir;
|
||||||
|
late ImagePickerPlatform originalImagePickerPlatform;
|
||||||
|
|
||||||
setUp(() async {
|
setUp(() async {
|
||||||
tempDir = await Directory.systemTemp.createTemp('diet_guard_builder_');
|
tempDir = await Directory.systemTemp.createTemp('diet_guard_builder_');
|
||||||
LogStorageService.resetForTesting(testDir: tempDir);
|
LogStorageService.resetForTesting(testDir: tempDir);
|
||||||
FoodBankService.resetForTesting(testDir: tempDir);
|
FoodBankService.resetForTesting(testDir: tempDir);
|
||||||
|
PhotoAttachService.resetForTesting(testDir: tempDir);
|
||||||
|
originalImagePickerPlatform = ImagePickerPlatform.instance;
|
||||||
});
|
});
|
||||||
|
|
||||||
tearDown(() async {
|
tearDown(() async {
|
||||||
LogStorageService.resetForTesting();
|
LogStorageService.resetForTesting();
|
||||||
FoodBankService.resetForTesting();
|
FoodBankService.resetForTesting();
|
||||||
|
PhotoAttachService.resetForTesting();
|
||||||
|
ImagePickerPlatform.instance = originalImagePickerPlatform;
|
||||||
await tempDir.delete(recursive: true);
|
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});
|
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