772 lines
24 KiB
Dart
772 lines
24 KiB
Dart
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),
|
||
],
|
||
);
|
||
}
|
||
}
|