feat: food search sheet with FTS+trgm, dish/recent endpoints, multilingual aliases

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>
This commit is contained in:
dbastrikin
2026-03-21 15:28:29 +02:00
parent 81185bb7ff
commit 78f1c8bf76
41 changed files with 1688 additions and 28 deletions

View File

@@ -0,0 +1,27 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/auth/auth_provider.dart';
import '../../shared/models/product.dart';
import 'food_search_service.dart';
final foodSearchServiceProvider = Provider<FoodSearchService>(
(ref) => FoodSearchService(ref.read(apiClientProvider)),
);
/// Recently used diary items (dishes + products).
final recentDiaryItemsProvider =
FutureProvider.autoDispose<List<RecentDiaryItem>>((ref) {
return ref.read(foodSearchServiceProvider).getRecent(limit: 15);
});
/// Product search results for the given query string.
final productSearchProvider = FutureProvider.autoDispose
.family<List<CatalogProduct>, String>((ref, query) {
return ref.read(foodSearchServiceProvider).searchProducts(query);
});
/// Dish search results for the given query string.
final dishSearchProvider = FutureProvider.autoDispose
.family<List<DishSearchResult>, String>((ref, query) {
return ref.read(foodSearchServiceProvider).searchDishes(query);
});

View File

@@ -0,0 +1,108 @@
import '../../core/api/api_client.dart';
import '../../shared/models/product.dart';
/// Lightweight dish result returned by GET /dishes/search.
class DishSearchResult {
final String id;
final String name;
final String? imageUrl;
final double avgRating;
const DishSearchResult({
required this.id,
required this.name,
this.imageUrl,
required this.avgRating,
});
factory DishSearchResult.fromJson(Map<String, dynamic> json) {
return DishSearchResult(
id: json['id'] as String,
name: json['name'] as String,
imageUrl: json['image_url'] as String?,
avgRating: (json['avg_rating'] as num?)?.toDouble() ?? 0,
);
}
}
/// One item from GET /diary/recent.
class RecentDiaryItem {
final String itemType; // "dish" | "product"
final String? dishId;
final String? productId;
final String name;
final String? imageUrl;
final String? categoryName;
final double? caloriesPer100g;
final double? caloriesPerServing;
const RecentDiaryItem({
required this.itemType,
this.dishId,
this.productId,
required this.name,
this.imageUrl,
this.categoryName,
this.caloriesPer100g,
this.caloriesPerServing,
});
factory RecentDiaryItem.fromJson(Map<String, dynamic> json) {
return RecentDiaryItem(
itemType: json['item_type'] as String? ?? 'dish',
dishId: json['dish_id'] as String?,
productId: json['product_id'] as String?,
name: json['name'] as String? ?? '',
imageUrl: json['image_url'] as String?,
categoryName: json['category_name'] as String?,
caloriesPer100g: (json['calories_per_100g'] as num?)?.toDouble(),
caloriesPerServing: (json['calories_per_serving'] as num?)?.toDouble(),
);
}
/// For products: calories per 100 g; for dishes: calories per serving.
double? get displayCalories =>
itemType == 'product' ? caloriesPer100g : caloriesPerServing;
}
/// Service for searching products/dishes and loading recently used diary items.
class FoodSearchService {
const FoodSearchService(this._client);
final ApiClient _client;
/// Searches catalog products by name.
Future<List<CatalogProduct>> searchProducts(String query) async {
if (query.isEmpty) return [];
final list = await _client.getList(
'/products/search',
params: {'q': query, 'limit': '20'},
);
return list
.map((item) => CatalogProduct.fromJson(item as Map<String, dynamic>))
.toList();
}
/// Searches dishes by name.
Future<List<DishSearchResult>> searchDishes(String query) async {
if (query.isEmpty) return [];
final list = await _client.getList(
'/dishes/search',
params: {'q': query, 'limit': '10'},
);
return list
.map((item) => DishSearchResult.fromJson(item as Map<String, dynamic>))
.toList();
}
/// Returns recent diary items (dishes and products) for the current user.
Future<List<RecentDiaryItem>> getRecent({int limit = 10}) async {
final list = await _client.getList(
'/diary/recent',
params: {'limit': '$limit'},
);
return list
.map((item) => RecentDiaryItem.fromJson(item as Map<String, dynamic>))
.toList();
}
}

View File

@@ -0,0 +1,689 @@
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),
),
],
),
);
}
}

View File

@@ -13,6 +13,7 @@ import '../../core/theme/app_colors.dart';
import '../../shared/models/diary_entry.dart';
import '../../shared/models/home_summary.dart';
import '../../shared/models/meal_type.dart';
import '../diary/food_search_sheet.dart';
import '../menu/menu_provider.dart';
import '../profile/profile_provider.dart';
import '../scan/dish_result_screen.dart';
@@ -966,8 +967,21 @@ class _MealCard extends ConsumerWidget {
icon: const Icon(Icons.add, size: 20),
visualDensity: VisualDensity.compact,
tooltip: l10n.addDish,
onPressed: () => _pickAndShowDishResult(
context, ref, mealTypeOption.id),
onPressed: () {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (_) => FoodSearchSheet(
mealType: mealTypeOption.id,
date: dateString,
onAdded: () => ref
.invalidate(diaryProvider(dateString)),
onScanDish: () => _pickAndShowDishResult(
context, ref, mealTypeOption.id),
),
);
},
),
],
),