glp-companion/lib/features/nutrition/presentation/screens/food_search_screen.dart

772 lines
24 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:io' show Platform;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import '../../../pantry/domain/entities/pantry_category.dart';
import '../../../pantry/presentation/controllers/pantry_inventory_controller.dart';
import '../../domain/entities/food_product.dart';
import '../../domain/entities/meal_type.dart';
import '../controllers/food_search_controller.dart';
import '../controllers/nutrition_controller.dart';
/// What the search flow should do with a selected product.
enum FoodSearchPreset {
/// Emphasize logging the product to the daily food diary.
log,
/// Emphasize adding the product to pantry stock (routed from the pantry
/// screen's "Scan Item" action).
pantry,
}
/// Parses the `?preset=` route parameter; unknown values fall back to
/// [FoodSearchPreset.log].
FoodSearchPreset foodSearchPresetFrom(String? raw) =>
raw == 'pantry' ? FoodSearchPreset.pantry : FoodSearchPreset.log;
/// Whether this device can run the camera barcode scanner.
///
/// The camera path is limited to iOS/Android; Web and desktop platforms
/// (Windows/macOS/Linux) fall back to a manual UPC entry dialog.
bool get supportsCameraScanner =>
!kIsWeb && (Platform.isAndroid || Platform.isIOS);
/// Nutritional product search: debounced free-text queries backed by the
/// local cache / Open Food Facts / USDA hierarchy plus UPC/EAN barcode
/// scanning (camera on mobile, manual entry on desktop/web).
///
/// Selecting a product opens an action sheet with full macros, a serving
/// multiplier, and "Log to Diary" / "Add to Pantry" actions.
class FoodSearchScreen extends ConsumerStatefulWidget {
const FoodSearchScreen({super.key, this.preset = FoodSearchPreset.log});
/// Route preset (see [foodSearchPresetFrom]).
final FoodSearchPreset preset;
@override
ConsumerState<FoodSearchScreen> createState() => _FoodSearchScreenState();
}
class _FoodSearchScreenState extends ConsumerState<FoodSearchScreen> {
final TextEditingController _searchController = TextEditingController();
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final state = ref.watch(foodSearchControllerProvider);
return Scaffold(
appBar: AppBar(
title: Text(
widget.preset == FoodSearchPreset.pantry
? 'Scan for Pantry'
: 'Search Foods',
),
actions: [
IconButton(
tooltip: supportsCameraScanner
? 'Scan barcode'
: 'Enter barcode manually',
icon: const Icon(Icons.qr_code_scanner),
onPressed: _startBarcodeCapture,
),
],
),
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720),
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: TextField(
controller: _searchController,
autofocus: true,
textInputAction: TextInputAction.search,
decoration: InputDecoration(
hintText: 'Search by name, brand, or barcode…',
prefixIcon: const Icon(Icons.search),
suffixIcon: state.query.isEmpty
? null
: IconButton(
tooltip: 'Clear search',
icon: const Icon(Icons.clear),
onPressed: _clearSearch,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
),
isDense: true,
),
onChanged: (value) {
ref
.read(foodSearchControllerProvider.notifier)
.onQueryChanged(value);
// Rebuild so the clear button appears/disappears.
setState(() {});
},
),
),
if (state.isSearching) const LinearProgressIndicator(),
Expanded(child: _buildResults(state)),
],
),
),
),
);
}
// ---------------------------------------------------------------------------
// Results
// ---------------------------------------------------------------------------
Widget _buildResults(FoodSearchState state) {
if (state.errorMessage != null && state.products.isEmpty) {
return _StatusView(
icon: Icons.error_outline,
iconColor: Theme.of(context).colorScheme.error,
title: 'Search unavailable',
message: state.errorMessage!,
actionLabel: 'Retry',
onAction: () =>
ref.read(foodSearchControllerProvider.notifier).retry(),
);
}
if (state.products.isEmpty) {
if (!state.hasSearched && state.query.isEmpty) {
return const _StatusView(
icon: Icons.search,
title: 'Find any food',
message: 'Search by name or brand, or scan a UPC/EAN barcode '
'with the camera button above.',
);
}
if (!state.isSearching && state.hasSearched) {
return const _StatusView(
icon: Icons.no_food,
title: 'No products found',
message: 'Try a different spelling, add a brand name, or scan '
'the barcode instead.',
);
}
return const SizedBox.shrink();
}
return ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: state.products.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) => _ProductTile(
product: state.products[index],
onTap: () => _showProductActions(state.products[index]),
),
);
}
// ---------------------------------------------------------------------------
// Barcode capture
// ---------------------------------------------------------------------------
void _clearSearch() {
_searchController.clear();
ref.read(foodSearchControllerProvider.notifier).clear();
setState(() {});
}
/// Mobile opens the camera scanner; desktop/web fall back to a manual UPC
/// dialog with an explanatory notification.
Future<void> _startBarcodeCapture() async {
if (!mounted) return;
if (supportsCameraScanner) {
final barcode = await _showScannerSheet();
if (barcode == null || !mounted) return;
_searchController.text = barcode;
await ref.read(foodSearchControllerProvider.notifier).lookupBarcode(
barcode,
);
setState(() {});
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
defaultTargetPlatform == TargetPlatform.windows ||
defaultTargetPlatform == TargetPlatform.linux
? 'Camera scanning is unavailable on desktop. '
'Enter the UPC/EAN manually.'
: 'Camera scanning is unavailable in this build. '
'Enter the UPC/EAN manually.',
),
duration: const Duration(seconds: 3),
),
);
final barcode = await _showManualBarcodeDialog();
if (barcode == null || !mounted) return;
_searchController.text = barcode;
await ref.read(foodSearchControllerProvider.notifier).lookupBarcode(
barcode,
);
setState(() {});
}
/// Full-height modal sheet hosting the live camera scanner; resolves with
/// the first detected UPC/EAN raw value.
Future<String?> _showScannerSheet() => showModalBottomSheet<String>(
context: context,
isScrollControlled: true,
builder: (context) => const _BarcodeScannerSheet(),
);
/// Desktop/web fallback: a numeric UPC/EAN entry dialog.
Future<String?> _showManualBarcodeDialog() {
final controller = TextEditingController();
return showDialog<String>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Enter barcode'),
content: TextField(
controller: controller,
autofocus: true,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'UPC / EAN',
hintText: 'e.g. 012345678905',
prefixIcon: Icon(Icons.qr_code),
),
onSubmitted: (value) {
final trimmed = value.trim();
Navigator.of(dialogContext).pop(trimmed.isEmpty ? null : trimmed);
},
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
final trimmed = controller.text.trim();
Navigator.of(dialogContext)
.pop(trimmed.isEmpty ? null : trimmed);
},
child: const Text('Look Up'),
),
],
),
).then((value) {
controller.dispose();
return value;
});
}
// ---------------------------------------------------------------------------
// Product action sheet
// ---------------------------------------------------------------------------
/// Shows the macro breakdown + serving multiplier sheet with "Log to
/// Diary" and "Add to Pantry" actions.
void _showProductActions(FoodProduct product) {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (sheetContext) => _ProductActionsSheet(
product: product,
preset: widget.preset,
),
);
}
}
/// Result row: thumbnail, name, brand, calories, and a protein badge.
class _ProductTile extends StatelessWidget {
const _ProductTile({required this.product, required this.onTap});
final FoodProduct product;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return ListTile(
leading: _Thumbnail(imageUrl: product.imageUrl),
title: Text(
product.productName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
[
if (product.brand != null) product.brand!,
'${product.calories.round()} kcal / serving',
].join(' · '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: scheme.secondaryContainer,
borderRadius: BorderRadius.circular(999),
),
child: Text(
'${product.proteinGrams.toStringAsFixed(1)}g',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: scheme.onSecondaryContainer,
fontWeight: FontWeight.w600,
),
),
),
onTap: onTap,
);
}
}
/// Product thumbnail with graceful placeholder / error fallback.
class _Thumbnail extends StatelessWidget {
const _Thumbnail({this.imageUrl});
final String? imageUrl;
@override
Widget build(BuildContext context) {
final url = imageUrl;
if (url == null || url.isEmpty) {
return const CircleAvatar(child: Icon(Icons.fastfood_outlined));
}
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
url,
width: 44,
height: 44,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const CircleAvatar(
radius: 22,
child: Icon(Icons.fastfood_outlined),
),
loadingBuilder: (context, child, progress) {
if (progress == null) return child;
return const SizedBox(
width: 44,
height: 44,
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
},
),
);
}
}
/// Centered empty/error placeholder with an optional action button.
class _StatusView extends StatelessWidget {
const _StatusView({
required this.icon,
required this.title,
required this.message,
this.iconColor,
this.actionLabel,
this.onAction,
});
final IconData icon;
final Color? iconColor;
final String title;
final String message;
final String? actionLabel;
final VoidCallback? onAction;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 48, color: iconColor),
const SizedBox(height: 12),
Text(title, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 4),
Text(message, textAlign: TextAlign.center),
if (actionLabel != null && onAction != null) ...[
const SizedBox(height: 16),
FilledButton.tonal(
onPressed: onAction,
child: Text(actionLabel!),
),
],
],
),
),
);
}
}
/// Modal sheet hosting the live [MobileScanner] camera preview.
///
/// Only ever instantiated on platforms where [supportsCameraScanner] is
/// true; desktop/web builds reach the manual entry dialog instead, so the
/// method-channel camera never initializes on unsupported runtimes.
class _BarcodeScannerSheet extends StatefulWidget {
const _BarcodeScannerSheet();
@override
State<_BarcodeScannerSheet> createState() => _BarcodeScannerSheetState();
}
class _BarcodeScannerSheetState extends State<_BarcodeScannerSheet> {
final MobileScannerController _controller = MobileScannerController(
detectionSpeed: DetectionSpeed.noDuplicates,
formats: const [
BarcodeFormat.upcA,
BarcodeFormat.upcE,
BarcodeFormat.ean8,
BarcodeFormat.ean13,
],
);
bool _handled = false;
String? _error;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _onDetect(BarcodeCapture capture) {
if (_handled) return;
final rawValue = capture.barcodes
.map((barcode) => barcode.rawValue)
.firstWhere((value) => value != null && value.isNotEmpty,
orElse: () => null);
if (rawValue == null) return;
_handled = true;
Navigator.of(context).pop(rawValue);
}
@override
Widget build(BuildContext context) {
return SizedBox(
height: MediaQuery.of(context).size.height * 0.75,
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(12),
child: Text(
'Point the camera at a UPC/EAN barcode',
style: Theme.of(context).textTheme.titleMedium,
),
),
if (_error != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
_error!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
Expanded(
child: ClipRRect(
borderRadius: const BorderRadius.vertical(
top: Radius.circular(12),
),
child: MobileScanner(
controller: _controller,
onDetect: _onDetect,
errorBuilder: (context, error, child) {
// Surface permission/hardware failures instead of a black
// preview; the user can still enter the code manually.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => _error = _describe(error));
});
return Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
_describe(error),
textAlign: TextAlign.center,
),
),
);
},
),
),
),
],
),
);
}
String _describe(MobileScannerException error) => switch (error.errorCode) {
MobileScannerErrorCode.permissionDenied =>
'Camera permission denied. Grant camera access in system settings '
'or enter the barcode manually.',
MobileScannerErrorCode.unsupported =>
'This device cannot scan barcodes with the camera.',
_ => 'Camera unavailable (${error.errorCode.name}).',
};
}
/// Serving-size multipliers offered by the action sheet.
const List<double> kServingMultipliers = [0.5, 1, 1.5, 2];
/// Action sheet for a resolved [FoodProduct]: full macro breakdown scaled by
/// the selected serving multiplier, plus "Log to Diary" and "Add to Pantry".
class _ProductActionsSheet extends ConsumerStatefulWidget {
const _ProductActionsSheet({required this.product, required this.preset});
final FoodProduct product;
/// Decides which action is visually primary.
final FoodSearchPreset preset;
@override
ConsumerState<_ProductActionsSheet> createState() =>
_ProductActionsSheetState();
}
class _ProductActionsSheetState extends ConsumerState<_ProductActionsSheet> {
double _multiplier = 1;
bool _busy = false;
FoodProduct get product => widget.product;
double _scaled(double value) => value * _multiplier;
Future<void> _logToDiary() async {
if (_busy) return;
setState(() => _busy = true);
final logged = await ref.read(nutritionControllerProvider.notifier).logFood(
mealType: _inferMealType(),
description: _description(),
proteinGrams: _scaled(product.proteinGrams),
calories: _scaled(product.calories).round(),
carbsGrams: _scaled(product.carbsGrams),
fatGrams: _scaled(product.fatGrams),
);
if (!mounted) return;
setState(() => _busy = false);
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
logged
? 'Logged ${_description()} to your diary.'
: 'Could not log this product.',
),
),
);
}
Future<void> _addToPantry() async {
if (_busy) return;
setState(() => _busy = true);
final added =
await ref.read(pantryInventoryControllerProvider.notifier).addItem(
name: product.productName,
quantityOnHand: _scaled(product.servingSizeGrams),
unit: product.servingSizeUnit,
category: PantryCategory.infer(product.productName),
);
if (!mounted) return;
setState(() => _busy = false);
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
added
? 'Added ${_description()} to your pantry.'
: 'Could not add this product to the pantry.',
),
),
);
}
/// `Greek Yogurt, Plain ×1.5` — the multiplier suffix is omitted at 1x.
String _description() => _multiplier == 1
? product.productName
: '${product.productName} ×$_multiplier';
/// Best-effort meal slot from the current time of day.
MealType _inferMealType() {
final hour = DateTime.now().hour;
if (hour < 10) return MealType.breakfast;
if (hour < 15) return MealType.lunch;
if (hour < 21) return MealType.dinner;
return MealType.snack;
}
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final pantryPrimary = widget.preset == FoodSearchPreset.pantry;
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(product.productName, style: textTheme.titleLarge),
if (product.brand != null) ...[
const SizedBox(height: 2),
Text(product.brand!, style: textTheme.bodyMedium),
],
const SizedBox(height: 12),
_MacroGrid(product: product, multiplier: _multiplier),
const SizedBox(height: 16),
Text('Servings', style: textTheme.titleSmall),
const SizedBox(height: 8),
SegmentedButton<double>(
segments: [
for (final multiplier in kServingMultipliers)
ButtonSegment(
value: multiplier,
label: Text('${multiplier}x'),
),
],
selected: {_multiplier},
onSelectionChanged: (selection) =>
setState(() => _multiplier = selection.first),
),
const SizedBox(height: 20),
if (_busy)
const Center(child: CircularProgressIndicator())
else ...[
FilledButton.icon(
onPressed: pantryPrimary ? _addToPantry : _logToDiary,
icon: Icon(
pantryPrimary
? Icons.kitchen_outlined
: Icons.restaurant_menu,
),
label: Text(
pantryPrimary ? 'Add to Pantry' : 'Log to Diary',
),
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: pantryPrimary ? _logToDiary : _addToPantry,
icon: Icon(
pantryPrimary
? Icons.restaurant_menu
: Icons.kitchen_outlined,
),
label: Text(
pantryPrimary ? 'Log to Diary' : 'Add to Pantry',
),
),
],
],
),
),
);
}
}
/// Two-column macro grid (calories, protein, carbs, fat, fiber, sodium)
/// scaled by the serving multiplier.
class _MacroGrid extends StatelessWidget {
const _MacroGrid({required this.product, required this.multiplier});
final FoodProduct product;
final double multiplier;
@override
Widget build(BuildContext context) {
final entries = <MapEntry<String, String>>[
MapEntry(
'Calories',
'${(product.calories * multiplier).round()} kcal',
),
MapEntry(
'Protein',
'${(product.proteinGrams * multiplier).toStringAsFixed(1)} g',
),
MapEntry(
'Carbs',
'${(product.carbsGrams * multiplier).toStringAsFixed(1)} g',
),
MapEntry(
'Fat',
'${(product.fatGrams * multiplier).toStringAsFixed(1)} g',
),
if (product.fiberGrams != null)
MapEntry(
'Fiber',
'${(product.fiberGrams! * multiplier).toStringAsFixed(1)} g',
),
if (product.sodiumMg != null)
MapEntry(
'Sodium',
'${(product.sodiumMg! * multiplier).round()} mg',
),
];
return Card(
margin: EdgeInsets.zero,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Theme.of(context).colorScheme.outlineVariant),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
children: [
for (var i = 0; i < entries.length; i += 2)
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Expanded(child: _MacroCell(entry: entries[i])),
if (i + 1 < entries.length)
Expanded(child: _MacroCell(entry: entries[i + 1]))
else
const Expanded(child: SizedBox()),
],
),
),
],
),
),
);
}
}
/// Single macro cell: label above, value below.
class _MacroCell extends StatelessWidget {
const _MacroCell({required this.entry});
final MapEntry<String, String> entry;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(entry.key, style: textTheme.labelMedium),
Text(entry.value, style: textTheme.titleSmall),
],
);
}
}