From 87ef2097fc44596e1b59b2d9d02560a448e69273 Mon Sep 17 00:00:00 2001 From: dbastrikin Date: Tue, 17 Mar 2026 14:29:36 +0200 Subject: [PATCH] feat: meal tracking, dish recognition UX improvements, English AI prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Translate all recognition prompts (receipt, products, dish) from Russian to English - Add lang parameter to Recognizer interface and pass locale.FromContext in handlers - DishResult type uses candidates array for multi-candidate responses Client: - Add meal tracking: diary provider, date selector, meal type model - DishResult parser: backward-compatible with legacy flat format and new candidates format - DishResultScreen: sticky bottom button, full-width portion/meal-type inputs, КБЖУ disclaimer moved under nutrition card, add date field to diary POST body - Recognition prompts now return dish/product names in user's preferred language - Onboarding, profile, home screen visual updates Co-Authored-By: Claude Sonnet 4.6 --- backend/internal/adapters/ai/types.go | 22 +- .../internal/adapters/openai/recognition.go | 135 ++-- .../internal/domain/recognition/handler.go | 16 +- backend/internal/domain/user/repository.go | 2 +- client/lib/core/router/app_router.dart | 15 +- client/lib/features/home/home_provider.dart | 13 + client/lib/features/home/home_screen.dart | 531 ++++++++++++---- .../onboarding/onboarding_screen.dart | 103 +++- .../lib/features/profile/profile_screen.dart | 43 +- .../lib/features/profile/profile_service.dart | 8 +- .../lib/features/scan/dish_result_screen.dart | 574 ++++++++++++++---- .../features/scan/recognition_service.dart | 51 +- client/lib/features/scan/scan_screen.dart | 47 +- client/lib/shared/models/meal_type.dart | 29 + client/lib/shared/models/user.dart | 10 + client/pubspec.lock | 20 +- 16 files changed, 1269 insertions(+), 350 deletions(-) create mode 100644 client/lib/shared/models/meal_type.dart diff --git a/backend/internal/adapters/ai/types.go b/backend/internal/adapters/ai/types.go index 1e88be2..bc12d53 100644 --- a/backend/internal/adapters/ai/types.go +++ b/backend/internal/adapters/ai/types.go @@ -94,16 +94,20 @@ type ReceiptResult struct { Unrecognized []UnrecognizedItem `json:"unrecognized"` } -// DishResult is the result of dish recognition. +// DishCandidate is a single dish recognition candidate with estimated nutrition. +type DishCandidate struct { + DishName string `json:"dish_name"` + WeightGrams int `json:"weight_grams"` + Calories float64 `json:"calories"` + ProteinG float64 `json:"protein_g"` + FatG float64 `json:"fat_g"` + CarbsG float64 `json:"carbs_g"` + Confidence float64 `json:"confidence"` +} + +// DishResult is the result of dish recognition with multiple ranked candidates. type DishResult struct { - DishName string `json:"dish_name"` - WeightGrams int `json:"weight_grams"` - Calories float64 `json:"calories"` - ProteinG float64 `json:"protein_g"` - FatG float64 `json:"fat_g"` - CarbsG float64 `json:"carbs_g"` - Confidence float64 `json:"confidence"` - SimilarDishes []string `json:"similar_dishes"` + Candidates []DishCandidate `json:"candidates"` } // IngredientTranslation holds the localized name and aliases for one language. diff --git a/backend/internal/adapters/openai/recognition.go b/backend/internal/adapters/openai/recognition.go index 4335ba9..7f153f8 100644 --- a/backend/internal/adapters/openai/recognition.go +++ b/backend/internal/adapters/openai/recognition.go @@ -9,30 +9,43 @@ import ( "github.com/food-ai/backend/internal/adapters/ai" ) -// RecognizeReceipt uses the vision model to extract food items from a receipt photo. -func (c *Client) RecognizeReceipt(ctx context.Context, imageBase64, mimeType string) (*ai.ReceiptResult, error) { - prompt := `Ты — OCR-система для чеков из продуктовых магазинов. +// langNames maps ISO 639-1 codes to English language names used in AI prompts. +var langNames = map[string]string{ + "en": "English", "ru": "Russian", "es": "Spanish", + "de": "German", "fr": "French", "it": "Italian", + "pt": "Portuguese", "zh": "Chinese", "ja": "Japanese", + "ko": "Korean", "ar": "Arabic", "hi": "Hindi", +} -Проанализируй фото чека и извлеки список продуктов питания. -Для каждого продукта определи: -- name: название на русском языке (убери артикулы, коды, лишние символы) -- quantity: количество (число) -- unit: единица (г, кг, мл, л, шт, уп) +// RecognizeReceipt uses the vision model to extract food items from a receipt photo. +func (c *Client) RecognizeReceipt(ctx context.Context, imageBase64, mimeType, lang string) (*ai.ReceiptResult, error) { + langName := langNames[lang] + if langName == "" { + langName = "English" + } + prompt := fmt.Sprintf(`You are an OCR system for grocery receipts. + +Analyse the receipt photo and extract a list of food products. +For each product determine: +- name: product name (remove article codes, extra symbols) +- quantity: amount (number) +- unit: unit (g, kg, ml, l, pcs, pack) - category: dairy | meat | produce | bakery | frozen | beverages | other - confidence: 0.0–1.0 -Позиции, которые не являются едой (бытовая химия, табак, алкоголь) — пропусти. -Позиции с нечитаемым текстом — добавь в unrecognized. +Skip items that are not food (household chemicals, tobacco, alcohol). +Items with unreadable text — add to unrecognized. -Верни ТОЛЬКО валидный JSON без markdown: +Return all text fields (name) in %s. +Return ONLY valid JSON without markdown: { "items": [ - {"name": "Молоко 2.5%", "quantity": 1, "unit": "л", "category": "dairy", "confidence": 0.95} + {"name": "...", "quantity": 1, "unit": "l", "category": "dairy", "confidence": 0.95} ], "unrecognized": [ - {"raw_text": "ТОВ АРТИК 1ШТ", "price": 89.0} + {"raw_text": "...", "price": 89.0} ] -}` +}`, langName) text, err := c.generateVisionContent(ctx, prompt, imageBase64, mimeType) if err != nil { @@ -53,25 +66,30 @@ func (c *Client) RecognizeReceipt(ctx context.Context, imageBase64, mimeType str } // RecognizeProducts uses the vision model to identify food items in a photo (fridge, shelf, etc.). -func (c *Client) RecognizeProducts(ctx context.Context, imageBase64, mimeType string) ([]ai.RecognizedItem, error) { - prompt := `Ты — система распознавания продуктов питания. +func (c *Client) RecognizeProducts(ctx context.Context, imageBase64, mimeType, lang string) ([]ai.RecognizedItem, error) { + langName := langNames[lang] + if langName == "" { + langName = "English" + } + prompt := fmt.Sprintf(`You are a food product recognition system. -Посмотри на фото и определи все видимые продукты питания. -Для каждого продукта оцени: -- name: название на русском языке -- quantity: приблизительное количество (число) -- unit: единица (г, кг, мл, л, шт) +Look at the photo and identify all visible food products. +For each product estimate: +- name: product name +- quantity: approximate amount (number) +- unit: unit (g, kg, ml, l, pcs) - category: dairy | meat | produce | bakery | frozen | beverages | other - confidence: 0.0–1.0 -Только продукты питания. Пустые упаковки и несъедобные предметы — пропусти. +Food products only. Skip empty packaging and inedible objects. -Верни ТОЛЬКО валидный JSON без markdown: +Return all text fields (name) in %s. +Return ONLY valid JSON without markdown: { "items": [ - {"name": "Яйца", "quantity": 10, "unit": "шт", "category": "dairy", "confidence": 0.9} + {"name": "...", "quantity": 10, "unit": "pcs", "category": "dairy", "confidence": 0.9} ] -}` +}`, langName) text, err := c.generateVisionContent(ctx, prompt, imageBase64, mimeType) if err != nil { @@ -91,28 +109,49 @@ func (c *Client) RecognizeProducts(ctx context.Context, imageBase64, mimeType st } // RecognizeDish uses the vision model to identify a dish and estimate its nutritional content. -func (c *Client) RecognizeDish(ctx context.Context, imageBase64, mimeType string) (*ai.DishResult, error) { - prompt := `Ты — диетолог и кулинарный эксперт. +// Returns 3–5 ranked candidates so the user can correct mis-identifications. +func (c *Client) RecognizeDish(ctx context.Context, imageBase64, mimeType, lang string) (*ai.DishResult, error) { + langName := langNames[lang] + if langName == "" { + langName = "English" + } + prompt := fmt.Sprintf(`You are a dietitian and culinary expert. -Посмотри на фото блюда и определи: -- dish_name: название блюда на русском языке -- weight_grams: приблизительный вес порции в граммах -- calories: калорийность порции (приблизительно) -- protein_g, fat_g, carbs_g: БЖУ на порцию -- confidence: 0.0–1.0 -- similar_dishes: до 3 похожих блюд (для поиска рецептов) +Look at the dish photo and suggest 3 to 5 possible dishes it could be. +Even if the first option is obvious, add 2–4 alternative dishes with lower confidence. +For each candidate specify: +- dish_name: dish name +- weight_grams: approximate portion weight in grams (estimate from photo) +- calories: calories for this portion (kcal) +- protein_g, fat_g, carbs_g: macros for this portion (grams) +- confidence: certainty 0.0–1.0 -Верни ТОЛЬКО валидный JSON без markdown: +Sort candidates by descending confidence. First — most likely. + +Return dish_name values in %s. +Return ONLY valid JSON without markdown: { - "dish_name": "Паста Карбонара", - "weight_grams": 350, - "calories": 520, - "protein_g": 22, - "fat_g": 26, - "carbs_g": 48, - "confidence": 0.85, - "similar_dishes": ["Паста с беконом", "Спагетти"] -}` + "candidates": [ + { + "dish_name": "...", + "weight_grams": 350, + "calories": 520, + "protein_g": 22, + "fat_g": 26, + "carbs_g": 48, + "confidence": 0.88 + }, + { + "dish_name": "...", + "weight_grams": 350, + "calories": 540, + "protein_g": 20, + "fat_g": 28, + "carbs_g": 49, + "confidence": 0.65 + } + ] +}`, langName) text, err := c.generateVisionContent(ctx, prompt, imageBase64, mimeType) if err != nil { @@ -120,11 +159,11 @@ func (c *Client) RecognizeDish(ctx context.Context, imageBase64, mimeType string } var result ai.DishResult - if err := parseJSON(text, &result); err != nil { - return nil, fmt.Errorf("parse dish result: %w", err) + if parseError := parseJSON(text, &result); parseError != nil { + return nil, fmt.Errorf("parse dish result: %w", parseError) } - if result.SimilarDishes == nil { - result.SimilarDishes = []string{} + if result.Candidates == nil { + result.Candidates = []ai.DishCandidate{} } return &result, nil } diff --git a/backend/internal/domain/recognition/handler.go b/backend/internal/domain/recognition/handler.go index 9b3a9b7..8040e47 100644 --- a/backend/internal/domain/recognition/handler.go +++ b/backend/internal/domain/recognition/handler.go @@ -9,6 +9,7 @@ import ( "sync" "github.com/food-ai/backend/internal/adapters/ai" + "github.com/food-ai/backend/internal/infra/locale" "github.com/food-ai/backend/internal/infra/middleware" "github.com/food-ai/backend/internal/domain/ingredient" ) @@ -23,9 +24,9 @@ type IngredientRepository interface { // Recognizer is the AI provider interface for image-based food recognition. type Recognizer interface { - RecognizeReceipt(ctx context.Context, imageBase64, mimeType string) (*ai.ReceiptResult, error) - RecognizeProducts(ctx context.Context, imageBase64, mimeType string) ([]ai.RecognizedItem, error) - RecognizeDish(ctx context.Context, imageBase64, mimeType string) (*ai.DishResult, error) + RecognizeReceipt(ctx context.Context, imageBase64, mimeType, lang string) (*ai.ReceiptResult, error) + RecognizeProducts(ctx context.Context, imageBase64, mimeType, lang string) ([]ai.RecognizedItem, error) + RecognizeDish(ctx context.Context, imageBase64, mimeType, lang string) (*ai.DishResult, error) ClassifyIngredient(ctx context.Context, name string) (*ai.IngredientClassification, error) } @@ -91,7 +92,8 @@ func (h *Handler) RecognizeReceipt(w http.ResponseWriter, r *http.Request) { return } - result, err := h.recognizer.RecognizeReceipt(r.Context(), req.ImageBase64, req.MimeType) + lang := locale.FromContext(r.Context()) + result, err := h.recognizer.RecognizeReceipt(r.Context(), req.ImageBase64, req.MimeType, lang) if err != nil { slog.Error("recognize receipt", "err", err) writeErrorJSON(w, http.StatusServiceUnavailable, "recognition failed, please try again") @@ -118,13 +120,14 @@ func (h *Handler) RecognizeProducts(w http.ResponseWriter, r *http.Request) { } // Process each image in parallel. + lang := locale.FromContext(r.Context()) allItems := make([][]ai.RecognizedItem, len(req.Images)) var wg sync.WaitGroup for i, img := range req.Images { wg.Add(1) go func(i int, img imageRequest) { defer wg.Done() - items, err := h.recognizer.RecognizeProducts(r.Context(), img.ImageBase64, img.MimeType) + items, err := h.recognizer.RecognizeProducts(r.Context(), img.ImageBase64, img.MimeType, lang) if err != nil { slog.Warn("recognize products from image", "index", i, "err", err) return @@ -148,7 +151,8 @@ func (h *Handler) RecognizeDish(w http.ResponseWriter, r *http.Request) { return } - result, err := h.recognizer.RecognizeDish(r.Context(), req.ImageBase64, req.MimeType) + lang := locale.FromContext(r.Context()) + result, err := h.recognizer.RecognizeDish(r.Context(), req.ImageBase64, req.MimeType, lang) if err != nil { slog.Error("recognize dish", "err", err) writeErrorJSON(w, http.StatusServiceUnavailable, "recognition failed, please try again") diff --git a/backend/internal/domain/user/repository.go b/backend/internal/domain/user/repository.go index 4108be5..d52b22a 100644 --- a/backend/internal/domain/user/repository.go +++ b/backend/internal/domain/user/repository.go @@ -112,7 +112,7 @@ func buildUpdateQuery(id string, req UpdateProfileRequest) (string, []interface{ argIdx++ } if req.Preferences != nil { - setClauses = append(setClauses, fmt.Sprintf("preferences = $%d", argIdx)) + setClauses = append(setClauses, fmt.Sprintf("preferences = preferences || $%d::jsonb", argIdx)) args = append(args, string(*req.Preferences)) argIdx++ } diff --git a/client/lib/core/router/app_router.dart b/client/lib/core/router/app_router.dart index e105b68..9f391d5 100644 --- a/client/lib/core/router/app_router.dart +++ b/client/lib/core/router/app_router.dart @@ -69,10 +69,15 @@ final routerProvider = Provider((ref) { } final profileUser = profileState.valueOrNull; // If profile failed to load, don't block navigation. - if (profileUser == null) return isAuthRoute ? '/home' : null; + if (profileUser == null) { + if (isAuthRoute || state.matchedLocation == '/loading') return '/home'; + return null; + } final needsOnboarding = !profileUser.hasCompletedOnboarding; - if (isAuthRoute) return needsOnboarding ? '/onboarding' : '/home'; + if (isAuthRoute || state.matchedLocation == '/loading') { + return needsOnboarding ? '/onboarding' : '/home'; + } if (needsOnboarding && !isOnboarding) return '/onboarding'; if (!needsOnboarding && isOnboarding) return '/home'; } @@ -148,9 +153,11 @@ final routerProvider = Provider((ref) { GoRoute( path: '/scan/dish', builder: (context, state) { - final dish = state.extra as DishResult?; + final extra = state.extra as Map?; + final dish = extra?['dish'] as DishResult?; + final mealType = extra?['meal_type'] as String?; if (dish == null) return const _InvalidRoute(); - return DishResultScreen(dish: dish); + return DishResultScreen(dish: dish, preselectedMealType: mealType); }, ), ShellRoute( diff --git a/client/lib/features/home/home_provider.dart b/client/lib/features/home/home_provider.dart index b4adc74..93bd3dc 100644 --- a/client/lib/features/home/home_provider.dart +++ b/client/lib/features/home/home_provider.dart @@ -3,6 +3,19 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../shared/models/home_summary.dart'; import 'home_service.dart'; +// ── Selected date (persists while app is open) ──────────────── + +/// The date currently viewed on the home screen. +/// Defaults to today; can be changed via the date selector. +final selectedDateProvider = StateProvider((ref) => DateTime.now()); + +/// Formats a [DateTime] to the 'YYYY-MM-DD' string expected by the diary API. +String formatDateForDiary(DateTime date) => + '${date.year}-${date.month.toString().padLeft(2, '0')}-' + '${date.day.toString().padLeft(2, '0')}'; + +// ── Home summary ────────────────────────────────────────────── + class HomeNotifier extends StateNotifier> { final HomeService _service; diff --git a/client/lib/features/home/home_screen.dart b/client/lib/features/home/home_screen.dart index f22811b..7a74c8a 100644 --- a/client/lib/features/home/home_screen.dart +++ b/client/lib/features/home/home_screen.dart @@ -6,60 +6,97 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; 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 '../menu/menu_provider.dart'; import '../profile/profile_provider.dart'; import 'home_provider.dart'; +// ── Root screen ─────────────────────────────────────────────── + class HomeScreen extends ConsumerWidget { const HomeScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - final state = ref.watch(homeProvider); - final userName = ref.watch(profileProvider).valueOrNull?.name; + final homeSummaryState = ref.watch(homeProvider); + final profile = ref.watch(profileProvider).valueOrNull; + final userName = profile?.name; + final goalType = profile?.goal; + final dailyGoal = profile?.dailyCalories ?? 0; + final userMealTypes = profile?.mealTypes ?? const ['breakfast', 'lunch', 'dinner']; - final goalType = ref.watch( - profileProvider.select((asyncUser) => asyncUser.valueOrNull?.goal)); + final selectedDate = ref.watch(selectedDateProvider); + final dateString = formatDateForDiary(selectedDate); + final diaryState = ref.watch(diaryProvider(dateString)); + final entries = diaryState.valueOrNull ?? []; + + final loggedCalories = entries.fold( + 0.0, (sum, entry) => sum + (entry.calories ?? 0)); + final loggedProtein = entries.fold( + 0.0, (sum, entry) => sum + (entry.proteinG ?? 0)); + final loggedFat = entries.fold( + 0.0, (sum, entry) => sum + (entry.fatG ?? 0)); + final loggedCarbs = entries.fold( + 0.0, (sum, entry) => sum + (entry.carbsG ?? 0)); + + final expiringSoon = homeSummaryState.valueOrNull?.expiringSoon ?? []; + final recommendations = homeSummaryState.valueOrNull?.recommendations ?? []; return Scaffold( - body: state.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (_, __) => Center( - child: FilledButton( - onPressed: () => ref.read(homeProvider.notifier).load(), - child: const Text('Повторить'), - ), - ), - data: (summary) => RefreshIndicator( - onRefresh: () => ref.read(homeProvider.notifier).load(), - child: CustomScrollView( - slivers: [ - _AppBar(summary: summary, userName: userName), - SliverPadding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 100), - sliver: SliverList( - delegate: SliverChildListDelegate([ + body: RefreshIndicator( + onRefresh: () async { + ref.read(homeProvider.notifier).load(); + ref.invalidate(diaryProvider(dateString)); + }, + child: CustomScrollView( + slivers: [ + _AppBar(userName: userName), + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 100), + sliver: SliverList( + delegate: SliverChildListDelegate([ + const SizedBox(height: 12), + _DateSelector( + selectedDate: selectedDate, + onDateSelected: (date) => + ref.read(selectedDateProvider.notifier).state = date, + ), + const SizedBox(height: 16), + _CaloriesCard( + loggedCalories: loggedCalories, + dailyGoal: dailyGoal, + goalType: goalType, + ), + const SizedBox(height: 12), + _MacrosRow( + proteinG: loggedProtein, + fatG: loggedFat, + carbsG: loggedCarbs, + ), + const SizedBox(height: 16), + _DailyMealsSection( + mealTypeIds: userMealTypes, + entries: entries, + dateString: dateString, + ), + if (expiringSoon.isNotEmpty) ...[ const SizedBox(height: 16), - _CaloriesCard(today: summary.today, goalType: goalType), - const SizedBox(height: 16), - _TodayMealsCard(plan: summary.today.plan), - if (summary.expiringSoon.isNotEmpty) ...[ - const SizedBox(height: 16), - _ExpiringBanner(items: summary.expiringSoon), - ], - const SizedBox(height: 16), - _QuickActionsRow(date: summary.today.date), - if (summary.recommendations.isNotEmpty) ...[ - const SizedBox(height: 20), - _SectionTitle('Рекомендуем приготовить'), - const SizedBox(height: 12), - _RecommendationsRow(recipes: summary.recommendations), - ], - ]), - ), + _ExpiringBanner(items: expiringSoon), + ], + const SizedBox(height: 16), + _QuickActionsRow(date: dateString), + if (recommendations.isNotEmpty) ...[ + const SizedBox(height: 20), + _SectionTitle('Рекомендуем приготовить'), + const SizedBox(height: 12), + _RecommendationsRow(recipes: recommendations), + ], + ]), ), - ], - ), + ), + ], ), ), ); @@ -69,9 +106,8 @@ class HomeScreen extends ConsumerWidget { // ── App bar ─────────────────────────────────────────────────── class _AppBar extends StatelessWidget { - final HomeSummary summary; final String? userName; - const _AppBar({required this.summary, this.userName}); + const _AppBar({this.userName}); String get _greetingBase { final hour = DateTime.now().hour; @@ -86,31 +122,89 @@ class _AppBar extends StatelessWidget { return _greetingBase; } - String get _dateLabel { - final now = DateTime.now(); - const months = [ - 'января', 'февраля', 'марта', 'апреля', 'мая', 'июня', - 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря', - ]; - return '${now.day} ${months[now.month - 1]}'; - } - @override Widget build(BuildContext context) { final theme = Theme.of(context); return SliverAppBar( pinned: false, floating: true, - title: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(_greeting, style: theme.textTheme.titleMedium), - Text( - _dateLabel, - style: theme.textTheme.bodySmall - ?.copyWith(color: theme.colorScheme.onSurfaceVariant), - ), - ], + title: Text(_greeting, style: theme.textTheme.titleMedium), + ); + } +} + +// ── Date selector ───────────────────────────────────────────── + +class _DateSelector extends StatelessWidget { + final DateTime selectedDate; + final ValueChanged onDateSelected; + + const _DateSelector({ + required this.selectedDate, + required this.onDateSelected, + }); + + static const _weekDayShort = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final today = DateTime.now(); + final todayNormalized = DateTime(today.year, today.month, today.day); + final selectedNormalized = DateTime( + selectedDate.year, selectedDate.month, selectedDate.day); + + return SizedBox( + height: 64, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: 7, + separatorBuilder: (_, __) => const SizedBox(width: 8), + itemBuilder: (context, index) { + final date = todayNormalized.subtract(Duration(days: 6 - index)); + final isSelected = date == selectedNormalized; + final isToday = date == todayNormalized; + + return GestureDetector( + onTap: () => onDateSelected(date), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + width: 44, + decoration: BoxDecoration( + color: isSelected + ? theme.colorScheme.primary + : theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + _weekDayShort[date.weekday - 1], + style: theme.textTheme.labelSmall?.copyWith( + color: isSelected + ? theme.colorScheme.onPrimary + : theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 2), + Text( + '${date.day}', + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: + isSelected || isToday ? FontWeight.w700 : FontWeight.normal, + color: isSelected + ? theme.colorScheme.onPrimary + : isToday + ? theme.colorScheme.primary + : null, + ), + ), + ], + ), + ), + ); + }, ), ); } @@ -119,31 +213,34 @@ class _AppBar extends StatelessWidget { // ── Calories card ───────────────────────────────────────────── class _CaloriesCard extends StatelessWidget { - final TodaySummary today; + final double loggedCalories; + final int dailyGoal; final String? goalType; - const _CaloriesCard({required this.today, required this.goalType}); + const _CaloriesCard({ + required this.loggedCalories, + required this.dailyGoal, + required this.goalType, + }); @override Widget build(BuildContext context) { - if (today.dailyGoal == 0) return const SizedBox.shrink(); + if (dailyGoal == 0) return const SizedBox.shrink(); final theme = Theme.of(context); - final logged = today.loggedCalories.toInt(); - final goal = today.dailyGoal; - final rawProgress = - goal > 0 ? today.loggedCalories / goal : 0.0; + final logged = loggedCalories.toInt(); + final rawProgress = dailyGoal > 0 ? loggedCalories / dailyGoal : 0.0; final isOverGoal = rawProgress > 1.0; final ringColor = _ringColorFor(rawProgress, goalType); final String secondaryLabel; final Color secondaryColor; if (isOverGoal) { - final overBy = (today.loggedCalories - goal).toInt(); + final overBy = (loggedCalories - dailyGoal).toInt(); secondaryLabel = '+$overBy перебор'; secondaryColor = AppColors.error; } else { - final remaining = today.remainingCalories.toInt(); + final remaining = (dailyGoal - loggedCalories).toInt(); secondaryLabel = 'осталось $remaining'; secondaryColor = AppColors.textSecondary; } @@ -184,7 +281,7 @@ class _CaloriesCard extends StatelessWidget { ), const SizedBox(height: 4), Text( - 'цель: $goal', + 'цель: $dailyGoal', style: theme.textTheme.labelSmall?.copyWith( color: AppColors.textSecondary, ), @@ -213,7 +310,7 @@ class _CaloriesCard extends StatelessWidget { const SizedBox(height: 12), _CalorieStat( label: 'Цель', - value: '$goal ккал', + value: '$dailyGoal ккал', valueColor: AppColors.textPrimary, ), ], @@ -245,8 +342,8 @@ class _CalorieStat extends StatelessWidget { children: [ Text( label, - style: theme.textTheme.labelSmall - ?.copyWith(color: AppColors.textSecondary), + style: + theme.textTheme.labelSmall?.copyWith(color: AppColors.textSecondary), ), const SizedBox(height: 2), Text( @@ -261,21 +358,104 @@ class _CalorieStat extends StatelessWidget { } } +// ── Macros row ──────────────────────────────────────────────── + +class _MacrosRow extends StatelessWidget { + final double proteinG; + final double fatG; + final double carbsG; + + const _MacrosRow({ + required this.proteinG, + required this.fatG, + required this.carbsG, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: _MacroChip( + label: 'Белки', + value: '${proteinG.toStringAsFixed(1)} г', + color: Colors.blue, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _MacroChip( + label: 'Жиры', + value: '${fatG.toStringAsFixed(1)} г', + color: Colors.orange, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _MacroChip( + label: 'Углеводы', + value: '${carbsG.toStringAsFixed(1)} г', + color: Colors.green, + ), + ), + ], + ); + } +} + +class _MacroChip extends StatelessWidget { + final String label; + final String value; + final Color color; + + const _MacroChip({ + required this.label, + required this.value, + required this.color, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + children: [ + Text( + value, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + color: color, + ), + ), + const SizedBox(height: 2), + Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } +} + // ── Ring colour logic ────────────────────────────────────────── -// Returns the ring stroke colour based on rawProgress and goal type. -// See docs/calorie_ring_color_spec.md for full specification. Color _ringColorFor(double rawProgress, String? goalType) { switch (goalType) { case 'lose': - // Ceiling semantics: over goal is bad if (rawProgress >= 1.10) return AppColors.error; if (rawProgress >= 0.95) return AppColors.warning; if (rawProgress >= 0.75) return AppColors.success; return AppColors.primary; case 'maintain': - // Bidirectional target: closeness in either direction is good if (rawProgress >= 1.25) return AppColors.error; if (rawProgress >= 1.11) return AppColors.warning; if (rawProgress >= 0.90) return AppColors.success; @@ -283,7 +463,6 @@ Color _ringColorFor(double rawProgress, String? goalType) { return AppColors.primary; case 'gain': - // Floor semantics: under goal is bad, over is neutral if (rawProgress < 0.60) return AppColors.error; if (rawProgress < 0.85) return AppColors.warning; if (rawProgress <= 1.15) return AppColors.success; @@ -308,13 +487,11 @@ class _CalorieRingPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { final center = Offset(size.width / 2, size.height / 2); - final radius = (size.width - 20) / 2; // 10 px inset on each side + final radius = (size.width - 20) / 2; const strokeWidth = 10.0; const overflowStrokeWidth = 6.0; - // Arc starts at 12 o'clock (−π/2) and goes clockwise const startAngle = -math.pi / 2; - // Background track — full circle final trackPaint = Paint() ..color = AppColors.separator.withValues(alpha: 0.5) ..style = PaintingStyle.stroke @@ -322,7 +499,6 @@ class _CalorieRingPainter extends CustomPainter { ..strokeCap = StrokeCap.round; canvas.drawCircle(center, radius, trackPaint); - // Primary arc — clamp sweep to max one full circle final clampedProgress = rawProgress.clamp(0.0, 1.0); if (clampedProgress > 0) { final arcPaint = Paint() @@ -339,7 +515,6 @@ class _CalorieRingPainter extends CustomPainter { ); } - // Overflow arc — second lap when rawProgress > 1.0 if (rawProgress > 1.0) { final overflowProgress = rawProgress - 1.0; final overflowPaint = Paint() @@ -363,72 +538,158 @@ class _CalorieRingPainter extends CustomPainter { oldDelegate.ringColor != ringColor; } -// ── Today meals card ────────────────────────────────────────── +// ── Daily meals section ─────────────────────────────────────── -class _TodayMealsCard extends StatelessWidget { - final List plan; - const _TodayMealsCard({required this.plan}); +class _DailyMealsSection extends ConsumerWidget { + final List mealTypeIds; + final List entries; + final String dateString; + + const _DailyMealsSection({ + required this.mealTypeIds, + required this.entries, + required this.dateString, + }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); - return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Text('Приёмы пищи сегодня', - style: theme.textTheme.titleSmall), - ), - Card( - child: Column( - children: plan.asMap().entries.map((entry) { - final i = entry.key; - final meal = entry.value; - return Column( - children: [ - _MealRow(meal: meal), - if (i < plan.length - 1) - const Divider(height: 1, indent: 16), - ], - ); - }).toList(), - ), - ), + Text('Приёмы пищи', style: theme.textTheme.titleSmall), + const SizedBox(height: 8), + ...mealTypeIds.map((mealTypeId) { + final mealTypeOption = mealTypeById(mealTypeId); + if (mealTypeOption == null) return const SizedBox.shrink(); + final mealEntries = entries + .where((entry) => entry.mealType == mealTypeId) + .toList(); + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _MealCard( + mealTypeOption: mealTypeOption, + entries: mealEntries, + dateString: dateString, + ), + ); + }), ], ); } } -class _MealRow extends StatelessWidget { - final TodayMealPlan meal; - const _MealRow({required this.meal}); +class _MealCard extends ConsumerWidget { + final MealTypeOption mealTypeOption; + final List entries; + final String dateString; + + const _MealCard({ + required this.mealTypeOption, + required this.entries, + required this.dateString, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final totalCalories = entries.fold( + 0.0, (sum, entry) => sum + (entry.calories ?? 0)); + + return Card( + child: Column( + children: [ + // Header row + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 8, 8), + child: Row( + children: [ + Text(mealTypeOption.emoji, + style: const TextStyle(fontSize: 20)), + const SizedBox(width: 8), + Text(mealTypeOption.label, + style: theme.textTheme.titleSmall), + const Spacer(), + if (totalCalories > 0) + Text( + '${totalCalories.toInt()} ккал', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + IconButton( + icon: const Icon(Icons.add, size: 20), + visualDensity: VisualDensity.compact, + tooltip: 'Добавить блюдо', + onPressed: () => + context.push('/scan', extra: mealTypeOption.id), + ), + ], + ), + ), + // Diary entries + if (entries.isNotEmpty) ...[ + const Divider(height: 1, indent: 16), + ...entries.map((entry) => _DiaryEntryTile( + entry: entry, + onDelete: () => ref + .read(diaryProvider(dateString).notifier) + .remove(entry.id), + )), + ], + ], + ), + ); + } +} + +class _DiaryEntryTile extends StatelessWidget { + final DiaryEntry entry; + final VoidCallback onDelete; + + const _DiaryEntryTile({required this.entry, required this.onDelete}); @override Widget build(BuildContext context) { final theme = Theme.of(context); + final calories = entry.calories?.toInt(); + final hasProtein = (entry.proteinG ?? 0) > 0; + final hasFat = (entry.fatG ?? 0) > 0; + final hasCarbs = (entry.carbsG ?? 0) > 0; return ListTile( - leading: Text(meal.mealEmoji, style: const TextStyle(fontSize: 24)), - title: Text(meal.mealLabel, style: theme.textTheme.labelMedium), - subtitle: meal.hasRecipe - ? Text(meal.recipeTitle!, style: theme.textTheme.bodyMedium) - : Text( - 'Не запланировано', - style: theme.textTheme.bodyMedium?.copyWith( + dense: true, + title: Text(entry.name, style: theme.textTheme.bodyMedium), + subtitle: (hasProtein || hasFat || hasCarbs) + ? Text( + [ + if (hasProtein) 'Б ${entry.proteinG!.toStringAsFixed(1)}', + if (hasFat) 'Ж ${entry.fatG!.toStringAsFixed(1)}', + if (hasCarbs) 'У ${entry.carbsG!.toStringAsFixed(1)}', + ].join(' '), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ) + : null, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (calories != null) + Text( + '$calories ккал', + style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, - fontStyle: FontStyle.italic, ), ), - trailing: meal.calories != null - ? Text( - '≈${meal.calories!.toInt()} ккал', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant), - ) - : const Icon(Icons.chevron_right), - onTap: () => context.push('/menu'), + IconButton( + icon: Icon(Icons.delete_outline, + size: 18, color: theme.colorScheme.error), + visualDensity: VisualDensity.compact, + onPressed: onDelete, + ), + ], + ), ); } } @@ -463,15 +724,17 @@ class _ExpiringBanner extends StatelessWidget { children: [ Text( 'Истекает срок годности', - style: theme.textTheme.labelMedium - ?.copyWith(color: onColor, fontWeight: FontWeight.w600), + style: theme.textTheme.labelMedium?.copyWith( + color: onColor, fontWeight: FontWeight.w600), ), Text( items .take(3) - .map((e) => '${e.name} — ${e.expiryLabel}') + .map((expiringSoonItem) => + '${expiringSoonItem.name} — ${expiringSoonItem.expiryLabel}') .join(', '), - style: theme.textTheme.bodySmall?.copyWith(color: onColor), + style: + theme.textTheme.bodySmall?.copyWith(color: onColor), ), ], ), @@ -598,7 +861,7 @@ class _RecipeCard extends StatelessWidget { child: Card( clipBehavior: Clip.antiAlias, child: InkWell( - onTap: () {}, // recipes detail can be added later + onTap: () {}, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/client/lib/features/onboarding/onboarding_screen.dart b/client/lib/features/onboarding/onboarding_screen.dart index 8cdeefa..c9fa291 100644 --- a/client/lib/features/onboarding/onboarding_screen.dart +++ b/client/lib/features/onboarding/onboarding_screen.dart @@ -4,10 +4,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../core/theme/app_colors.dart'; +import '../../shared/models/meal_type.dart'; import '../profile/profile_provider.dart'; import '../profile/profile_service.dart'; -const int _totalSteps = 6; +const int _totalSteps = 7; const List _stepAccentColors = [ Color(0xFF5856D6), // Goal — iOS purple @@ -15,6 +16,7 @@ const List _stepAccentColors = [ Color(0xFFFF9500), // DOB — iOS orange Color(0xFF34C759), // Height + Weight — iOS green Color(0xFFFF2D55), // Activity — iOS pink + Color(0xFF30B0C7), // Meal Types — teal Color(0xFFFF6B00), // Calories — deep orange ]; @@ -24,6 +26,7 @@ const List _stepIcons = [ Icons.cake_outlined, Icons.monitor_weight_outlined, Icons.directions_run, + Icons.restaurant_menu, Icons.local_fire_department, ]; @@ -45,6 +48,7 @@ class _OnboardingScreenState extends ConsumerState { final _heightController = TextEditingController(); final _weightController = TextEditingController(); String? _activity; + List _mealTypes = List.from(kDefaultMealTypeIds); int _dailyCalories = 2000; bool _saving = false; @@ -116,7 +120,9 @@ class _OnboardingScreenState extends ConsumerState { weightValue <= 300; case 4: // Activity return _activity != null; - case 5: // Calories + case 5: // Meal Types — at least one must be selected + return _mealTypes.isNotEmpty; + case 6: // Calories return true; default: return false; @@ -127,7 +133,7 @@ class _OnboardingScreenState extends ConsumerState { if (!_canAdvance()) return; // Entering calorie review — pre-calculate based on collected data - if (_currentStep == 4) { + if (_currentStep == 5) { setState(() => _dailyCalories = _calculateCalories()); } @@ -166,6 +172,7 @@ class _OnboardingScreenState extends ConsumerState { gender: _gender, goal: _goal, activity: _activity, + mealTypes: _mealTypes, dailyCalories: _dailyCalories, ); @@ -257,9 +264,19 @@ class _OnboardingScreenState extends ConsumerState { _StepPage( accentColor: _stepAccentColors[5], icon: _stepIcons[5], + child: _MealTypesStepContent( + selected: _mealTypes, + accentColor: _stepAccentColors[5], + onChanged: (updated) => + setState(() => _mealTypes = updated), + ), + ), + _StepPage( + accentColor: _stepAccentColors[6], + icon: _stepIcons[6], child: _CalorieStepContent( calories: _dailyCalories, - accentColor: _stepAccentColors[5], + accentColor: _stepAccentColors[6], onChanged: (value) => setState(() => _dailyCalories = value), ), @@ -1108,7 +1125,83 @@ class _ActivityCard extends StatelessWidget { } } -// ── Step 6: Calorie review ───────────────────────────────────── +// ── Step 6: Meal types ───────────────────────────────────────── + +class _MealTypesStepContent extends StatelessWidget { + final List selected; + final Color accentColor; + final ValueChanged> onChanged; + + const _MealTypesStepContent({ + required this.selected, + required this.accentColor, + required this.onChanged, + }); + + void _toggle(String mealTypeId) { + final updated = List.from(selected); + if (updated.contains(mealTypeId)) { + // Keep at least one meal type selected. + if (updated.length > 1) updated.remove(mealTypeId); + } else { + updated.add(mealTypeId); + } + onChanged(updated); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Ваши приёмы пищи', style: theme.textTheme.headlineSmall), + const SizedBox(height: 4), + Text( + 'Выберите, какие приёмы пищи вы хотите отслеживать', + style: theme.textTheme.bodyMedium + ?.copyWith(color: AppColors.textSecondary), + ), + const SizedBox(height: 20), + ...kAllMealTypes.map((mealTypeOption) { + final isSelected = selected.contains(mealTypeOption.id); + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _SelectableCard( + selected: isSelected, + accentColor: accentColor, + onTap: () => _toggle(mealTypeOption.id), + child: Row( + children: [ + Text(mealTypeOption.emoji, + style: const TextStyle(fontSize: 24)), + const SizedBox(width: 12), + Expanded( + child: Text( + mealTypeOption.label, + style: theme.textTheme.bodyLarge?.copyWith( + color: isSelected + ? accentColor + : AppColors.textPrimary, + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.normal, + ), + ), + ), + if (isSelected) + Icon(Icons.check_circle, color: accentColor), + ], + ), + ), + ); + }), + ], + ); + } +} + +// ── Step 7: Calorie review ───────────────────────────────────── class _CalorieStepContent extends StatelessWidget { final int calories; diff --git a/client/lib/features/profile/profile_screen.dart b/client/lib/features/profile/profile_screen.dart index 9c5813c..efd12d1 100644 --- a/client/lib/features/profile/profile_screen.dart +++ b/client/lib/features/profile/profile_screen.dart @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/auth/auth_provider.dart'; import '../../core/locale/language_provider.dart'; import '../../core/theme/app_colors.dart'; +import '../../shared/models/meal_type.dart'; import '../../shared/models/user.dart'; import 'profile_provider.dart'; import 'profile_service.dart'; @@ -92,7 +93,7 @@ class _ProfileBody extends ConsumerWidget { ]), const SizedBox(height: 16), - // Calories + // Calories + meal types _SectionLabel('ПИТАНИЕ'), const SizedBox(height: 6), _InfoCard(children: [ @@ -102,6 +103,14 @@ class _ProfileBody extends ConsumerWidget { ? '${user.dailyCalories} ккал/день' : null, ), + const Divider(height: 1, indent: 16), + _InfoRow( + 'Приёмы пищи', + user.mealTypes + .map((mealTypeId) => + mealTypeById(mealTypeId)?.label ?? mealTypeId) + .join(', '), + ), ]), if (user.dailyCalories != null) ...[ const SizedBox(height: 4), @@ -342,6 +351,7 @@ class _EditProfileSheetState extends ConsumerState { String? _goal; String? _activity; String? _language; + List _mealTypes = []; bool _saving = false; @override @@ -358,6 +368,7 @@ class _EditProfileSheetState extends ConsumerState { _goal = u.goal; _activity = u.activity; _language = u.preferences['language'] as String? ?? 'ru'; + _mealTypes = List.from(u.mealTypes); } @override @@ -400,6 +411,7 @@ class _EditProfileSheetState extends ConsumerState { goal: _goal, activity: _activity, language: _language, + mealTypes: _mealTypes, ); final ok = await ref.read(profileProvider.notifier).update(req); @@ -590,12 +602,39 @@ class _EditProfileSheetState extends ConsumerState { ), const SizedBox(height: 20), + // Meal types + Text('Приёмы пищи', style: theme.textTheme.labelMedium), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 6, + children: kAllMealTypes.map((mealTypeOption) { + final isSelected = + _mealTypes.contains(mealTypeOption.id); + return FilterChip( + label: Text( + '${mealTypeOption.emoji} ${mealTypeOption.label}'), + selected: isSelected, + onSelected: (selected) { + setState(() { + if (selected) { + _mealTypes.add(mealTypeOption.id); + } else if (_mealTypes.length > 1) { + _mealTypes.remove(mealTypeOption.id); + } + }); + }, + ); + }).toList(), + ), + const SizedBox(height: 20), + // Language ref.watch(supportedLanguagesProvider).when( data: (languages) => DropdownButtonFormField( - value: _language, decoration: const InputDecoration( labelText: 'Язык интерфейса'), + initialValue: _language, items: languages.entries .map((e) => DropdownMenuItem( value: e.key, diff --git a/client/lib/features/profile/profile_service.dart b/client/lib/features/profile/profile_service.dart index 340f217..3f2f0a6 100644 --- a/client/lib/features/profile/profile_service.dart +++ b/client/lib/features/profile/profile_service.dart @@ -17,6 +17,7 @@ class UpdateProfileRequest { final String? activity; final String? goal; final String? language; + final List? mealTypes; final int? dailyCalories; const UpdateProfileRequest({ @@ -28,6 +29,7 @@ class UpdateProfileRequest { this.activity, this.goal, this.language, + this.mealTypes, this.dailyCalories, }); @@ -40,8 +42,12 @@ class UpdateProfileRequest { if (gender != null) map['gender'] = gender; if (activity != null) map['activity'] = activity; if (goal != null) map['goal'] = goal; - if (language != null) map['preferences'] = {'language': language}; if (dailyCalories != null) map['daily_calories'] = dailyCalories; + // Build preferences patch — backend merges into existing JSONB. + final prefPatch = {}; + if (language != null) prefPatch['language'] = language; + if (mealTypes != null) prefPatch['meal_types'] = mealTypes; + if (prefPatch.isNotEmpty) map['preferences'] = prefPatch; return map; } } diff --git a/client/lib/features/scan/dish_result_screen.dart b/client/lib/features/scan/dish_result_screen.dart index 9d05a2d..5aa44ba 100644 --- a/client/lib/features/scan/dish_result_screen.dart +++ b/client/lib/features/scan/dish_result_screen.dart @@ -1,134 +1,396 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../features/menu/menu_provider.dart'; +import '../../features/home/home_provider.dart'; +import '../../shared/models/meal_type.dart'; import 'recognition_service.dart'; -/// Shows the nutritional breakdown of a recognized dish. -class DishResultScreen extends StatelessWidget { - const DishResultScreen({super.key, required this.dish}); +/// Shows the recognition candidates and lets the user confirm a dish entry +/// before adding it to the diary. +class DishResultScreen extends ConsumerStatefulWidget { + const DishResultScreen({ + super.key, + required this.dish, + this.preselectedMealType, + }); final DishResult dish; + final String? preselectedMealType; + + @override + ConsumerState createState() => _DishResultScreenState(); +} + +class _DishResultScreenState extends ConsumerState { + late int _selectedIndex; + late int _portionGrams; + late String _mealType; + bool _saving = false; + + final TextEditingController _portionController = TextEditingController(); + + @override + void initState() { + super.initState(); + _selectedIndex = 0; + _portionGrams = widget.dish.candidates.isNotEmpty + ? widget.dish.candidates.first.weightGrams + : 300; + _mealType = widget.preselectedMealType ?? + kAllMealTypes.first.id; + _portionController.text = '$_portionGrams'; + } + + @override + void dispose() { + _portionController.dispose(); + super.dispose(); + } + + DishCandidate get _selected => widget.dish.candidates[_selectedIndex]; + + /// Scales nutrition linearly to the current portion weight. + double _scale(double baseValue) { + final baseWeight = _selected.weightGrams; + if (baseWeight <= 0) return baseValue; + return baseValue * _portionGrams / baseWeight; + } + + void _selectCandidate(int index) { + setState(() { + _selectedIndex = index; + _portionGrams = widget.dish.candidates[index].weightGrams; + _portionController.text = '$_portionGrams'; + }); + } + + void _adjustPortion(int delta) { + final newValue = (_portionGrams + delta).clamp(10, 9999); + setState(() { + _portionGrams = newValue; + _portionController.text = '$newValue'; + }); + } + + void _onPortionEdited(String value) { + final parsed = int.tryParse(value); + if (parsed != null && parsed >= 10) { + setState(() => _portionGrams = parsed.clamp(10, 9999)); + } + } + + Future _addToDiary() async { + if (_saving) return; + setState(() => _saving = true); + + final selectedDate = ref.read(selectedDateProvider); + final dateString = formatDateForDiary(selectedDate); + + final scaledCalories = _scale(_selected.calories); + final scaledProtein = _scale(_selected.proteinG); + final scaledFat = _scale(_selected.fatG); + final scaledCarbs = _scale(_selected.carbsG); + + try { + await ref.read(diaryProvider(dateString).notifier).add({ + 'date': dateString, + 'meal_type': _mealType, + 'name': _selected.dishName, + 'calories': scaledCalories, + 'protein_g': scaledProtein, + 'fat_g': scaledFat, + 'carbs_g': scaledCarbs, + 'portion_g': _portionGrams, + 'source': 'recognition', + }); + if (mounted) context.go('/home'); + } catch (addError) { + debugPrint('Add to diary error: $addError'); + if (mounted) { + setState(() => _saving = false); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Не удалось добавить. Попробуйте ещё раз.')), + ); + } + } + } @override Widget build(BuildContext context) { final theme = Theme.of(context); - final confPct = (dish.confidence * 100).toInt(); + final hasCandidates = widget.dish.candidates.isNotEmpty; return Scaffold( appBar: AppBar(title: const Text('Распознано блюдо')), - body: ListView( - padding: const EdgeInsets.all(20), - children: [ - // Dish name + confidence - Text( - dish.dishName, - style: theme.textTheme.headlineSmall - ?.copyWith(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 4), - Row( - children: [ - Icon( - Icons.info_outline, - size: 14, - color: theme.colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 4), - Text( - 'Уверенность: $confPct%', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, + bottomNavigationBar: hasCandidates + ? SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 16), + child: FilledButton( + onPressed: _saving ? null : _addToDiary, + child: _saving + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Добавить в журнал'), ), ), - ], - ), - const SizedBox(height: 24), - - // Nutrition card - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - '≈ ${dish.calories.toInt()} ккал', - style: theme.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - color: theme.colorScheme.primary, - ), - ), - const Spacer(), - Tooltip( - message: 'Приблизительные значения на основе фото', - child: Text( - '≈', - style: theme.textTheme.titleLarge?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ), - ], + ) + : null, + body: hasCandidates + ? ListView( + padding: const EdgeInsets.all(20), + children: [ + _CandidatesSection( + candidates: widget.dish.candidates, + selectedIndex: _selectedIndex, + onSelect: _selectCandidate, + ), + const SizedBox(height: 20), + _NutritionCard( + calories: _scale(_selected.calories), + proteinG: _scale(_selected.proteinG), + fatG: _scale(_selected.fatG), + carbsG: _scale(_selected.carbsG), + ), + const SizedBox(height: 8), + Text( + 'КБЖУ приблизительные — определены по фото.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, ), - const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - _MacroChip( - label: 'Белки', - value: '${dish.proteinG.toStringAsFixed(1)} г', - color: Colors.blue, - ), - _MacroChip( - label: 'Жиры', - value: '${dish.fatG.toStringAsFixed(1)} г', - color: Colors.orange, - ), - _MacroChip( - label: 'Углеводы', - value: '${dish.carbsG.toStringAsFixed(1)} г', - color: Colors.green, - ), - ], + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + _PortionRow( + controller: _portionController, + onMinus: () => _adjustPortion(-10), + onPlus: () => _adjustPortion(10), + onChanged: _onPortionEdited, + ), + const SizedBox(height: 20), + _MealTypeDropdown( + selected: _mealType, + onChanged: (value) { + if (value != null) setState(() => _mealType = value); + }, + ), + const SizedBox(height: 16), + ], + ) + : Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Блюдо не распознано', + style: theme.textTheme.titleMedium, ), const SizedBox(height: 8), - Text( - 'Вес порции: ~${dish.weightGrams} г', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), + FilledButton( + onPressed: () => context.pop(), + child: const Text('Попробовать снова'), ), ], ), ), - ), + ); + } +} - // Similar dishes - if (dish.similarDishes.isNotEmpty) ...[ - const SizedBox(height: 20), - Text('Похожие блюда', style: theme.textTheme.titleSmall), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 4, - children: dish.similarDishes - .map((name) => Chip(label: Text(name))) - .toList(), +// --------------------------------------------------------------------------- +// Candidates selector +// --------------------------------------------------------------------------- + +class _CandidatesSection extends StatelessWidget { + const _CandidatesSection({ + required this.candidates, + required this.selectedIndex, + required this.onSelect, + }); + + final List candidates; + final int selectedIndex; + final ValueChanged onSelect; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Выберите блюдо', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + ...candidates.asMap().entries.map((entry) { + final index = entry.key; + final candidate = entry.value; + final confPct = (candidate.confidence * 100).toInt(); + final isSelected = index == selectedIndex; + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () => onSelect(index), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected + ? theme.colorScheme.primary + : theme.colorScheme.outlineVariant, + width: isSelected ? 2 : 1, + ), + color: isSelected + ? theme.colorScheme.primaryContainer + .withValues(alpha: 0.3) + : null, + ), + child: Row( + children: [ + Icon( + isSelected + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + color: isSelected + ? theme.colorScheme.primary + : theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + candidate.dishName, + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.normal, + ), + ), + ), + _ConfidenceBadge(confidence: confPct), + ], + ), + ), + ), + ); + }), + ], + ); + } +} + +class _ConfidenceBadge extends StatelessWidget { + const _ConfidenceBadge({required this.confidence}); + + final int confidence; + + @override + Widget build(BuildContext context) { + final Color badgeColor; + if (confidence >= 80) { + badgeColor = Colors.green; + } else if (confidence >= 50) { + badgeColor = Colors.orange; + } else { + badgeColor = Colors.red; + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: badgeColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '$confidence%', + style: TextStyle( + color: badgeColor, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Nutrition card +// --------------------------------------------------------------------------- + +class _NutritionCard extends StatelessWidget { + const _NutritionCard({ + required this.calories, + required this.proteinG, + required this.fatG, + required this.carbsG, + }); + + final double calories; + final double proteinG; + final double fatG; + final double carbsG; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + '≈ ${calories.toInt()} ккал', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.primary, + ), + ), + const Spacer(), + Tooltip( + message: 'Приблизительные значения на основе фото', + child: Text( + '≈', + style: theme.textTheme.titleLarge?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _MacroChip( + label: 'Белки', + value: '${proteinG.toStringAsFixed(1)} г', + color: Colors.blue, + ), + _MacroChip( + label: 'Жиры', + value: '${fatG.toStringAsFixed(1)} г', + color: Colors.orange, + ), + _MacroChip( + label: 'Углеводы', + value: '${carbsG.toStringAsFixed(1)} г', + color: Colors.green, + ), + ], ), ], - - const SizedBox(height: 32), - const Divider(), - const SizedBox(height: 8), - Text( - 'КБЖУ приблизительные — определены по фото.', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - textAlign: TextAlign.center, - ), - ], + ), ), ); } @@ -157,9 +419,103 @@ class _MacroChip extends StatelessWidget { color: color, ), ), - Text( - label, - style: Theme.of(context).textTheme.labelSmall, + Text(label, style: Theme.of(context).textTheme.labelSmall), + ], + ); + } +} + +// --------------------------------------------------------------------------- +// Portion row +// --------------------------------------------------------------------------- + +class _PortionRow extends StatelessWidget { + const _PortionRow({ + required this.controller, + required this.onMinus, + required this.onPlus, + required this.onChanged, + }); + + final TextEditingController controller; + final VoidCallback onMinus; + final VoidCallback onPlus; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Порция', style: theme.textTheme.titleSmall), + const SizedBox(height: 8), + Row( + children: [ + IconButton.outlined( + icon: const Icon(Icons.remove), + onPressed: onMinus, + ), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: controller, + textAlign: TextAlign.center, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + onChanged: onChanged, + decoration: const InputDecoration( + suffixText: 'г', + border: OutlineInputBorder(), + ), + ), + ), + const SizedBox(width: 8), + IconButton.outlined( + icon: const Icon(Icons.add), + onPressed: onPlus, + ), + ], + ), + ], + ); + } +} + +// --------------------------------------------------------------------------- +// Meal type dropdown +// --------------------------------------------------------------------------- + +class _MealTypeDropdown extends StatelessWidget { + const _MealTypeDropdown({ + required this.selected, + required this.onChanged, + }); + + final String selected; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Приём пищи', style: theme.textTheme.titleSmall), + const SizedBox(height: 8), + DropdownButtonFormField( + initialValue: selected, + decoration: const InputDecoration( + border: OutlineInputBorder(), + ), + items: kAllMealTypes + .map((mealTypeOption) => DropdownMenuItem( + value: mealTypeOption.id, + child: Text( + '${mealTypeOption.emoji} ${mealTypeOption.label}'), + )) + .toList(), + onChanged: onChanged, ), ], ); diff --git a/client/lib/features/scan/recognition_service.dart b/client/lib/features/scan/recognition_service.dart index 456bcf9..5d56073 100644 --- a/client/lib/features/scan/recognition_service.dart +++ b/client/lib/features/scan/recognition_service.dart @@ -61,7 +61,8 @@ class ReceiptResult { const ReceiptResult({required this.items, required this.unrecognized}); } -class DishResult { +/// A single dish recognition candidate with estimated nutrition for the portion in the photo. +class DishCandidate { final String dishName; final int weightGrams; final double calories; @@ -69,9 +70,8 @@ class DishResult { final double fatG; final double carbsG; final double confidence; - final List similarDishes; - const DishResult({ + const DishCandidate({ required this.dishName, required this.weightGrams, required this.calories, @@ -79,11 +79,10 @@ class DishResult { required this.fatG, required this.carbsG, required this.confidence, - required this.similarDishes, }); - factory DishResult.fromJson(Map json) { - return DishResult( + factory DishCandidate.fromJson(Map json) { + return DishCandidate( dishName: json['dish_name'] as String? ?? '', weightGrams: json['weight_grams'] as int? ?? 0, calories: (json['calories'] as num?)?.toDouble() ?? 0, @@ -91,14 +90,46 @@ class DishResult { fatG: (json['fat_g'] as num?)?.toDouble() ?? 0, carbsG: (json['carbs_g'] as num?)?.toDouble() ?? 0, confidence: (json['confidence'] as num?)?.toDouble() ?? 0, - similarDishes: (json['similar_dishes'] as List?) - ?.map((e) => e as String) - .toList() ?? - [], ); } } +/// Result of dish recognition: ordered list of candidates (best match first). +class DishResult { + final List candidates; + + const DishResult({required this.candidates}); + + /// The best matching candidate. + DishCandidate get best => candidates.first; + + // Convenience getters delegating to the best candidate. + String get dishName => best.dishName; + int get weightGrams => best.weightGrams; + double get calories => best.calories; + double get proteinG => best.proteinG; + double get fatG => best.fatG; + double get carbsG => best.carbsG; + double get confidence => best.confidence; + + factory DishResult.fromJson(Map json) { + // New format: {"candidates": [...]} + if (json['candidates'] is List) { + final candidatesList = (json['candidates'] as List) + .map((element) => DishCandidate.fromJson(element as Map)) + .toList(); + return DishResult(candidates: candidatesList); + } + + // Legacy flat format: {"dish_name": "...", "calories": ..., ...} + if (json['dish_name'] != null) { + return DishResult(candidates: [DishCandidate.fromJson(json)]); + } + + return const DishResult(candidates: []); + } +} + // --------------------------------------------------------------------------- // Service // --------------------------------------------------------------------------- diff --git a/client/lib/features/scan/scan_screen.dart b/client/lib/features/scan/scan_screen.dart index 4e4d480..eddb48d 100644 --- a/client/lib/features/scan/scan_screen.dart +++ b/client/lib/features/scan/scan_screen.dart @@ -12,11 +12,36 @@ final _recognitionServiceProvider = Provider((ref) { }); /// Entry screen — lets the user choose how to add products. -class ScanScreen extends ConsumerWidget { +/// If [GoRouterState.extra] is a non-null String, it is treated as a meal type ID +/// and the screen immediately opens the camera for dish recognition. +class ScanScreen extends ConsumerStatefulWidget { const ScanScreen({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _ScanScreenState(); +} + +class _ScanScreenState extends ConsumerState { + bool _autoStarted = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_autoStarted) return; + final mealType = GoRouterState.of(context).extra as String?; + if (mealType != null && mealType.isNotEmpty) { + _autoStarted = true; + // Defer to avoid calling context navigation during build. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _pickAndRecognize(context, _Mode.dish, mealType: mealType); + } + }); + } + } + + @override + Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Добавить продукты')), body: ListView( @@ -33,21 +58,21 @@ class ScanScreen extends ConsumerWidget { emoji: '🧾', title: 'Сфотографировать чек', subtitle: 'Распознаем все продукты из чека', - onTap: () => _pickAndRecognize(context, ref, _Mode.receipt), + onTap: () => _pickAndRecognize(context, _Mode.receipt), ), const SizedBox(height: 16), _ModeCard( emoji: '🥦', title: 'Сфотографировать продукты', subtitle: 'Холодильник, стол, полка — до 3 фото', - onTap: () => _pickAndRecognize(context, ref, _Mode.products), + onTap: () => _pickAndRecognize(context, _Mode.products), ), const SizedBox(height: 16), _ModeCard( emoji: '🍽️', title: 'Определить блюдо', subtitle: 'КБЖУ≈ по фото готового блюда', - onTap: () => _pickAndRecognize(context, ref, _Mode.dish), + onTap: () => _pickAndRecognize(context, _Mode.dish), ), const SizedBox(height: 16), _ModeCard( @@ -63,9 +88,9 @@ class ScanScreen extends ConsumerWidget { Future _pickAndRecognize( BuildContext context, - WidgetRef ref, - _Mode mode, - ) async { + _Mode mode, { + String? mealType, + }) async { final picker = ImagePicker(); List files = []; @@ -120,11 +145,11 @@ class ScanScreen extends ConsumerWidget { final dish = await service.recognizeDish(files.first); if (context.mounted) { Navigator.pop(context); - context.push('/scan/dish', extra: dish); + context.push('/scan/dish', extra: {'dish': dish, 'meal_type': mealType}); } } - } catch (e, s) { - debugPrint('Recognition error: $e\n$s'); + } catch (recognitionError) { + debugPrint('Recognition error: $recognitionError'); if (context.mounted) { Navigator.pop(context); // close loading ScaffoldMessenger.of(context).showSnackBar( diff --git a/client/lib/shared/models/meal_type.dart b/client/lib/shared/models/meal_type.dart new file mode 100644 index 0000000..c5958aa --- /dev/null +++ b/client/lib/shared/models/meal_type.dart @@ -0,0 +1,29 @@ +/// A configurable meal type that the user tracks throughout the day. +class MealTypeOption { + final String id; + final String label; + final String emoji; + + const MealTypeOption({ + required this.id, + required this.label, + required this.emoji, + }); +} + +/// All meal types available for selection. +const kAllMealTypes = [ + MealTypeOption(id: 'breakfast', label: 'Завтрак', emoji: '🌅'), + MealTypeOption(id: 'second_breakfast', label: 'Второй завтрак', emoji: '☕'), + MealTypeOption(id: 'lunch', label: 'Обед', emoji: '🍽️'), + MealTypeOption(id: 'afternoon_snack', label: 'Полдник', emoji: '🥗'), + MealTypeOption(id: 'dinner', label: 'Ужин', emoji: '🌙'), + MealTypeOption(id: 'snack', label: 'Перекус', emoji: '🍎'), +]; + +/// Default meal type IDs assigned to new users. +const kDefaultMealTypeIds = ['breakfast', 'lunch', 'dinner']; + +/// Returns the [MealTypeOption] for the given [id], or null if not found. +MealTypeOption? mealTypeById(String id) => + kAllMealTypes.where((option) => option.id == id).firstOrNull; diff --git a/client/lib/shared/models/user.dart b/client/lib/shared/models/user.dart index a49a83b..89ce00f 100644 --- a/client/lib/shared/models/user.dart +++ b/client/lib/shared/models/user.dart @@ -1,5 +1,7 @@ import 'package:json_annotation/json_annotation.dart'; +import 'meal_type.dart'; + part 'user.g.dart'; @JsonSerializable() @@ -59,4 +61,12 @@ class User { bool get hasCompletedOnboarding => heightCm != null && weightKg != null && dateOfBirth != null && gender != null && goal != null && activity != null; + + /// Returns the user's configured meal type IDs from preferences, + /// falling back to the default set if not yet configured. + List get mealTypes { + final value = preferences['meal_types']; + if (value is List && value.isNotEmpty) return List.from(value); + return List.from(kDefaultMealTypeIds); + } } diff --git a/client/pubspec.lock b/client/pubspec.lock index 79c5cdf..df7fdde 100644 --- a/client/pubspec.lock +++ b/client/pubspec.lock @@ -125,10 +125,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" checked_yaml: dependency: transitive description: @@ -668,26 +668,26 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" meta: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.16.0" mime: dependency: transitive description: @@ -993,10 +993,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.6" typed_data: dependency: transitive description: