Backend: - GET /dishes/search — hybrid FTS (english + simple) + trgm + ILIKE search - GET /diary/recent — recently used dishes and products for the current user - product search upgraded: FTS on canonical_name and product_aliases, ranked by GREATEST(ts_rank, similarity) - importoff: collect product_name_ru/de/fr/... as product_aliases for multilingual search (e.g. "сникерс" → "Snickers") - migrations: FTS + trgm indexes merged into 001_initial_schema.sql (002 removed) Flutter: - FoodSearchSheet: debounced search field, recently-used section, product/dish results, scan-photo and barcode chips - DishPortionSheet: quick ½/1/1½/2 buttons + custom input - + button in meal card now opens FoodSearchSheet instead of going directly to AI scan - 7 new l10n keys across all 12 languages Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
690 lines
21 KiB
Dart
690 lines
21 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../../core/auth/auth_provider.dart';
|
|
import '../../l10n/app_localizations.dart';
|
|
import '../../shared/models/product.dart';
|
|
import 'barcode_scan_screen.dart';
|
|
import 'food_search_provider.dart';
|
|
import 'food_search_service.dart';
|
|
import 'product_portion_sheet.dart';
|
|
|
|
/// Bottom sheet for searching and selecting food (product or dish) to add to diary.
|
|
///
|
|
/// When the search query is empty the sheet shows recently used items.
|
|
/// When the search query is non-empty it shows product and dish search results.
|
|
class FoodSearchSheet extends ConsumerStatefulWidget {
|
|
const FoodSearchSheet({
|
|
super.key,
|
|
required this.mealType,
|
|
required this.date,
|
|
required this.onAdded,
|
|
this.onScanDish,
|
|
});
|
|
|
|
final String mealType;
|
|
final String date;
|
|
|
|
/// Called after any diary entry has been successfully added.
|
|
final VoidCallback onAdded;
|
|
|
|
/// Optional callback to trigger AI dish-from-photo recognition.
|
|
/// When null the scan-photo chip is hidden.
|
|
final VoidCallback? onScanDish;
|
|
|
|
@override
|
|
ConsumerState<FoodSearchSheet> createState() => _FoodSearchSheetState();
|
|
}
|
|
|
|
class _FoodSearchSheetState extends ConsumerState<FoodSearchSheet> {
|
|
final TextEditingController _queryController = TextEditingController();
|
|
Timer? _debounce;
|
|
String _activeQuery = '';
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_queryController.addListener(_onQueryChanged);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_debounce?.cancel();
|
|
_queryController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _onQueryChanged() {
|
|
_debounce?.cancel();
|
|
_debounce = Timer(const Duration(milliseconds: 300), () {
|
|
final trimmed = _queryController.text.trim();
|
|
if (trimmed != _activeQuery) {
|
|
setState(() => _activeQuery = trimmed);
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _addProductToDiary(
|
|
CatalogProduct catalogProduct, double portionGrams) async {
|
|
await ref.read(apiClientProvider).post('/diary', data: {
|
|
'product_id': catalogProduct.id,
|
|
'portion_g': portionGrams,
|
|
'meal_type': widget.mealType,
|
|
'date': widget.date,
|
|
'source': 'search',
|
|
});
|
|
}
|
|
|
|
void _openProductPortion(CatalogProduct catalogProduct) {
|
|
showModalBottomSheet<void>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
useSafeArea: true,
|
|
builder: (sheetContext) => Padding(
|
|
padding: EdgeInsets.only(
|
|
bottom: MediaQuery.viewInsetsOf(sheetContext).bottom,
|
|
),
|
|
child: ProductPortionSheet(
|
|
catalogProduct: catalogProduct,
|
|
onConfirm: (portionGrams) async {
|
|
try {
|
|
await _addProductToDiary(catalogProduct, portionGrams);
|
|
widget.onAdded();
|
|
if (mounted) Navigator.pop(context);
|
|
} catch (_) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(AppLocalizations.of(context)!.addFailed),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _openDishPortion(DishSearchResult dish) {
|
|
showModalBottomSheet<void>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
useSafeArea: true,
|
|
builder: (_) => _DishPortionSheet(
|
|
dishId: dish.id,
|
|
dishName: dish.name,
|
|
mealType: widget.mealType,
|
|
date: widget.date,
|
|
onAdded: () {
|
|
widget.onAdded();
|
|
Navigator.pop(context);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
void _openRecentItem(RecentDiaryItem recentItem) {
|
|
if (recentItem.itemType == 'product' && recentItem.productId != null) {
|
|
final catalogProduct = CatalogProduct(
|
|
id: recentItem.productId!,
|
|
canonicalName: recentItem.name,
|
|
categoryName: recentItem.categoryName,
|
|
caloriesPer100g: recentItem.caloriesPer100g,
|
|
);
|
|
_openProductPortion(catalogProduct);
|
|
} else if (recentItem.itemType == 'dish' && recentItem.dishId != null) {
|
|
showModalBottomSheet<void>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
useSafeArea: true,
|
|
builder: (_) => _DishPortionSheet(
|
|
dishId: recentItem.dishId!,
|
|
dishName: recentItem.name,
|
|
caloriesPerServing: recentItem.caloriesPerServing,
|
|
mealType: widget.mealType,
|
|
date: widget.date,
|
|
onAdded: () {
|
|
widget.onAdded();
|
|
Navigator.pop(context);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
void _openBarcodeScanner() {
|
|
Navigator.pop(context);
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute<void>(
|
|
builder: (_) => BarcodeScanScreen(
|
|
mealType: widget.mealType,
|
|
date: widget.date,
|
|
onAdded: widget.onAdded,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
final theme = Theme.of(context);
|
|
|
|
return DraggableScrollableSheet(
|
|
expand: false,
|
|
initialChildSize: 0.92,
|
|
minChildSize: 0.5,
|
|
maxChildSize: 0.95,
|
|
builder: (sheetContext, scrollController) {
|
|
return Column(
|
|
children: [
|
|
// Drag handle
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
|
child: Container(
|
|
width: 32,
|
|
height: 4,
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.onSurfaceVariant
|
|
.withValues(alpha: 0.4),
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Search field
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: TextField(
|
|
controller: _queryController,
|
|
autofocus: true,
|
|
decoration: InputDecoration(
|
|
hintText: l10n.searchFoodHint,
|
|
prefixIcon: const Icon(Icons.search),
|
|
suffixIcon: _activeQuery.isNotEmpty
|
|
? IconButton(
|
|
icon: const Icon(Icons.clear),
|
|
onPressed: () {
|
|
_queryController.clear();
|
|
setState(() => _activeQuery = '');
|
|
},
|
|
)
|
|
: null,
|
|
border: const OutlineInputBorder(),
|
|
contentPadding:
|
|
const EdgeInsets.symmetric(vertical: 12),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Quick action chips
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
|
child: Wrap(
|
|
spacing: 8,
|
|
children: [
|
|
if (widget.onScanDish != null)
|
|
ActionChip(
|
|
avatar:
|
|
const Icon(Icons.camera_alt_outlined, size: 18),
|
|
label: Text(l10n.scanDishPhoto),
|
|
onPressed: () {
|
|
Navigator.pop(context);
|
|
widget.onScanDish!();
|
|
},
|
|
),
|
|
ActionChip(
|
|
avatar: const Icon(Icons.qr_code_scanner, size: 18),
|
|
label: Text(l10n.scanBarcode),
|
|
onPressed: _openBarcodeScanner,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
// Results area
|
|
Expanded(
|
|
child: _activeQuery.isEmpty
|
|
? _RecentSection(
|
|
scrollController: scrollController,
|
|
onTap: _openRecentItem,
|
|
)
|
|
: _SearchResults(
|
|
query: _activeQuery,
|
|
scrollController: scrollController,
|
|
onTapProduct: _openProductPortion,
|
|
onTapDish: _openDishPortion,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Recently used section
|
|
// ---------------------------------------------------------------------------
|
|
|
|
class _RecentSection extends ConsumerWidget {
|
|
const _RecentSection({
|
|
required this.scrollController,
|
|
required this.onTap,
|
|
});
|
|
|
|
final ScrollController scrollController;
|
|
final void Function(RecentDiaryItem) onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
final recentState = ref.watch(recentDiaryItemsProvider);
|
|
|
|
return recentState.when(
|
|
loading: () => const Center(child: CircularProgressIndicator()),
|
|
error: (_, __) => const SizedBox.shrink(),
|
|
data: (recentItems) {
|
|
if (recentItems.isEmpty) return const SizedBox.shrink();
|
|
return ListView.builder(
|
|
controller: scrollController,
|
|
itemCount: recentItems.length + 1, // +1 for header
|
|
itemBuilder: (context, index) {
|
|
if (index == 0) {
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
|
child: Text(
|
|
l10n.recentlyUsedLabel,
|
|
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
|
color:
|
|
Theme.of(context).colorScheme.onSurfaceVariant,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
final recentItem = recentItems[index - 1];
|
|
return _FoodTile.fromRecent(
|
|
recentItem: recentItem,
|
|
onTap: () => onTap(recentItem),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Search results section
|
|
// ---------------------------------------------------------------------------
|
|
|
|
class _SearchResults extends ConsumerWidget {
|
|
const _SearchResults({
|
|
required this.query,
|
|
required this.scrollController,
|
|
required this.onTapProduct,
|
|
required this.onTapDish,
|
|
});
|
|
|
|
final String query;
|
|
final ScrollController scrollController;
|
|
final void Function(CatalogProduct) onTapProduct;
|
|
final void Function(DishSearchResult) onTapDish;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
final theme = Theme.of(context);
|
|
final productsState = ref.watch(productSearchProvider(query));
|
|
final dishesState = ref.watch(dishSearchProvider(query));
|
|
|
|
if (productsState.isLoading && dishesState.isLoading) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
|
|
final products = productsState.valueOrNull ?? [];
|
|
final dishes = dishesState.valueOrNull ?? [];
|
|
|
|
if (products.isEmpty && dishes.isEmpty) {
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(32),
|
|
child: Text(
|
|
l10n.noResultsForQuery(query),
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
final items = <_ListItem>[];
|
|
if (products.isNotEmpty) {
|
|
items.add(_SectionHeader(l10n.productsSection));
|
|
items.addAll(products.map(_ProductItem.new));
|
|
}
|
|
if (dishes.isNotEmpty) {
|
|
items.add(_SectionHeader(l10n.dishesSection));
|
|
items.addAll(dishes.map(_DishItem.new));
|
|
}
|
|
|
|
return ListView.builder(
|
|
controller: scrollController,
|
|
itemCount: items.length,
|
|
itemBuilder: (context, index) {
|
|
final listItem = items[index];
|
|
if (listItem is _SectionHeader) {
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
|
child: Text(
|
|
listItem.title,
|
|
style: theme.textTheme.labelMedium?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
);
|
|
} else if (listItem is _ProductItem) {
|
|
return _FoodTile.fromProduct(
|
|
catalogProduct: listItem.catalogProduct,
|
|
onTap: () => onTapProduct(listItem.catalogProduct),
|
|
);
|
|
} else if (listItem is _DishItem) {
|
|
return _FoodTile.fromDish(
|
|
dish: listItem.dish,
|
|
onTap: () => onTapDish(listItem.dish),
|
|
);
|
|
}
|
|
return const SizedBox.shrink();
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Flat-list item types ───────────────────────────────────────
|
|
|
|
sealed class _ListItem {}
|
|
|
|
final class _SectionHeader extends _ListItem {
|
|
final String title;
|
|
_SectionHeader(this.title);
|
|
}
|
|
|
|
final class _ProductItem extends _ListItem {
|
|
final CatalogProduct catalogProduct;
|
|
_ProductItem(this.catalogProduct);
|
|
}
|
|
|
|
final class _DishItem extends _ListItem {
|
|
final DishSearchResult dish;
|
|
_DishItem(this.dish);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Universal food tile
|
|
// ---------------------------------------------------------------------------
|
|
|
|
class _FoodTile extends StatelessWidget {
|
|
const _FoodTile({
|
|
required this.leading,
|
|
required this.title,
|
|
this.subtitle,
|
|
required this.onTap,
|
|
});
|
|
|
|
final Widget leading;
|
|
final String title;
|
|
final String? subtitle;
|
|
final VoidCallback onTap;
|
|
|
|
factory _FoodTile.fromProduct({
|
|
required CatalogProduct catalogProduct,
|
|
required VoidCallback onTap,
|
|
}) {
|
|
final calories = catalogProduct.caloriesPer100g;
|
|
final parts = <String>[
|
|
if (catalogProduct.categoryName != null) catalogProduct.categoryName!,
|
|
if (calories != null) '${calories.toInt()} kcal/100g',
|
|
];
|
|
return _FoodTile(
|
|
leading: CircleAvatar(
|
|
radius: 20,
|
|
backgroundColor: Colors.orange.shade50,
|
|
child: const Icon(Icons.fastfood_outlined,
|
|
size: 20, color: Colors.orange),
|
|
),
|
|
title: catalogProduct.displayName,
|
|
subtitle: parts.isNotEmpty ? parts.join(' · ') : null,
|
|
onTap: onTap,
|
|
);
|
|
}
|
|
|
|
factory _FoodTile.fromDish({
|
|
required DishSearchResult dish,
|
|
required VoidCallback onTap,
|
|
}) {
|
|
return _FoodTile(
|
|
leading: dish.imageUrl != null
|
|
? CircleAvatar(
|
|
radius: 20,
|
|
backgroundImage: NetworkImage(dish.imageUrl!),
|
|
)
|
|
: CircleAvatar(
|
|
radius: 20,
|
|
backgroundColor: Colors.green.shade50,
|
|
child: const Icon(Icons.restaurant,
|
|
size: 20, color: Colors.green),
|
|
),
|
|
title: dish.name,
|
|
subtitle: null,
|
|
onTap: onTap,
|
|
);
|
|
}
|
|
|
|
factory _FoodTile.fromRecent({
|
|
required RecentDiaryItem recentItem,
|
|
required VoidCallback onTap,
|
|
}) {
|
|
final calories = recentItem.displayCalories;
|
|
final parts = <String>[
|
|
if (recentItem.categoryName != null) recentItem.categoryName!,
|
|
if (calories != null)
|
|
recentItem.itemType == 'product'
|
|
? '${calories.toInt()} kcal/100g'
|
|
: '${calories.toInt()} kcal/serving',
|
|
];
|
|
return _FoodTile(
|
|
leading: recentItem.imageUrl != null
|
|
? CircleAvatar(
|
|
radius: 20,
|
|
backgroundImage: NetworkImage(recentItem.imageUrl!),
|
|
)
|
|
: CircleAvatar(
|
|
radius: 20,
|
|
backgroundColor: recentItem.itemType == 'product'
|
|
? Colors.orange.shade50
|
|
: Colors.green.shade50,
|
|
child: Icon(
|
|
recentItem.itemType == 'product'
|
|
? Icons.fastfood_outlined
|
|
: Icons.restaurant,
|
|
size: 20,
|
|
color: recentItem.itemType == 'product'
|
|
? Colors.orange
|
|
: Colors.green,
|
|
),
|
|
),
|
|
title: recentItem.name,
|
|
subtitle: parts.isNotEmpty ? parts.join(' · ') : null,
|
|
onTap: onTap,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ListTile(
|
|
leading: leading,
|
|
title: Text(title),
|
|
subtitle: subtitle != null ? Text(subtitle!) : null,
|
|
onTap: onTap,
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Dish portion sheet
|
|
// ---------------------------------------------------------------------------
|
|
|
|
class _DishPortionSheet extends ConsumerStatefulWidget {
|
|
const _DishPortionSheet({
|
|
required this.dishId,
|
|
required this.dishName,
|
|
this.caloriesPerServing,
|
|
required this.mealType,
|
|
required this.date,
|
|
required this.onAdded,
|
|
});
|
|
|
|
final String dishId;
|
|
final String dishName;
|
|
final double? caloriesPerServing;
|
|
final String mealType;
|
|
final String date;
|
|
final VoidCallback onAdded;
|
|
|
|
@override
|
|
ConsumerState<_DishPortionSheet> createState() => _DishPortionSheetState();
|
|
}
|
|
|
|
class _DishPortionSheetState extends ConsumerState<_DishPortionSheet> {
|
|
double _selectedPortions = 1.0;
|
|
bool _saving = false;
|
|
late final TextEditingController _portionsController =
|
|
TextEditingController(text: '1');
|
|
|
|
@override
|
|
void dispose() {
|
|
_portionsController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _setPortions(double value) {
|
|
setState(() {
|
|
_selectedPortions = value;
|
|
_portionsController.text = value % 1 == 0
|
|
? value.toInt().toString()
|
|
: value.toStringAsFixed(1);
|
|
});
|
|
}
|
|
|
|
Future<void> _confirm() async {
|
|
final parsed = double.tryParse(_portionsController.text);
|
|
final portions =
|
|
(parsed != null && parsed > 0) ? parsed : _selectedPortions;
|
|
setState(() => _saving = true);
|
|
try {
|
|
await ref.read(apiClientProvider).post('/diary', data: {
|
|
'dish_id': widget.dishId,
|
|
'portions': portions,
|
|
'meal_type': widget.mealType,
|
|
'date': widget.date,
|
|
'source': 'search',
|
|
});
|
|
widget.onAdded();
|
|
} catch (_) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(AppLocalizations.of(context)!.addFailed),
|
|
),
|
|
);
|
|
}
|
|
} finally {
|
|
if (mounted) setState(() => _saving = false);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
final theme = Theme.of(context);
|
|
final insets = MediaQuery.viewInsetsOf(context);
|
|
|
|
return Padding(
|
|
padding: EdgeInsets.fromLTRB(16, 16, 16, 16 + insets.bottom),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Text(widget.dishName, style: theme.textTheme.titleMedium),
|
|
if (widget.caloriesPerServing != null)
|
|
Text(
|
|
'${widget.caloriesPerServing!.toInt()} kcal / serving',
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// Quick-select portion buttons
|
|
Row(
|
|
children: [
|
|
for (final quickValue in [0.5, 1.0, 1.5, 2.0])
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: 8),
|
|
child: OutlinedButton(
|
|
onPressed: () => _setPortions(quickValue),
|
|
style: OutlinedButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 12, vertical: 8),
|
|
side: BorderSide(
|
|
color: _selectedPortions == quickValue
|
|
? theme.colorScheme.primary
|
|
: theme.colorScheme.outline,
|
|
width: _selectedPortions == quickValue ? 2 : 1,
|
|
),
|
|
),
|
|
child: Text(quickValue % 1 == 0
|
|
? quickValue.toInt().toString()
|
|
: quickValue.toStringAsFixed(1)),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
|
|
TextField(
|
|
controller: _portionsController,
|
|
keyboardType:
|
|
const TextInputType.numberWithOptions(decimal: true),
|
|
decoration: InputDecoration(
|
|
labelText: l10n.servingsLabel,
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
FilledButton(
|
|
onPressed: _saving ? null : _confirm,
|
|
child: _saving
|
|
? const SizedBox(
|
|
height: 20,
|
|
width: 20,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: Text(l10n.addToDiary),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|