feat: core schema redesign — dishes, structured recipes, cuisines, tags (iteration 7)
Replaces the flat JSONB-based recipe schema with a normalized relational model:
Schema (migrations consolidated to 001_initial_schema + 002_seed_data):
- New: dishes, dish_translations, dish_tags — canonical dish catalog
- New: cuisines, tags, dish_categories with _translations tables + full seed data
- New: recipe_ingredients, recipe_steps with _translations (replaces JSONB blobs)
- New: user_saved_recipes thin bookmark (drops saved_recipes + saved_recipe_translations)
- New: product_ingredients M2M table
- recipes: now a cooking variant of a dish (dish_id FK, no title/JSONB columns)
- recipe_translations: repurposed to per-language notes only
- products: mapping_id → primary_ingredient_id
- menu_items: recipe_id FK → recipes; adds dish_id
- meal_diary: adds dish_id, recipe_id → recipes, portion_g
Backend (Go):
- New packages: internal/cuisine, internal/tag, internal/dish (registry + handler + repo)
- New GET /cuisines, GET /tags (public), GET /dishes, GET /dishes/{id}, GET /recipes/{id}
- recipe, savedrecipe, menu, diary, product, ingredient packages updated for new schema
Flutter:
- New models: Cuisine, Tag; new providers: cuisineNamesProvider, tagNamesProvider
- recipe.dart: RecipeIngredient gains unit_code + effectiveUnit getter
- saved_recipe.dart: thin model, manual fromJson, computed nutrition getter
- diary_entry.dart: adds dishId, recipeId, portionG
- recipe_detail_screen.dart: localized cuisine/tag names via providers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,8 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/locale/cuisine_provider.dart';
|
||||
import '../../core/locale/tag_provider.dart';
|
||||
import '../../core/locale/unit_provider.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../shared/models/recipe.dart';
|
||||
@@ -200,7 +202,7 @@ class _PlaceholderImage extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
class _MetaChips extends StatelessWidget {
|
||||
class _MetaChips extends ConsumerWidget {
|
||||
final int? prepTimeMin;
|
||||
final int? cookTimeMin;
|
||||
final String? difficulty;
|
||||
@@ -216,8 +218,9 @@ class _MetaChips extends StatelessWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final totalMin = (prepTimeMin ?? 0) + (cookTimeMin ?? 0);
|
||||
final cuisineNames = ref.watch(cuisineNamesProvider).valueOrNull ?? {};
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
@@ -227,7 +230,9 @@ class _MetaChips extends StatelessWidget {
|
||||
if (difficulty != null)
|
||||
_Chip(icon: Icons.bar_chart, label: _difficultyLabel(difficulty!)),
|
||||
if (cuisine != null)
|
||||
_Chip(icon: Icons.public, label: _cuisineLabel(cuisine!)),
|
||||
_Chip(
|
||||
icon: Icons.public,
|
||||
label: cuisineNames[cuisine!] ?? cuisine!),
|
||||
if (servings != null)
|
||||
_Chip(icon: Icons.people, label: '$servings порц.'),
|
||||
],
|
||||
@@ -240,15 +245,6 @@ class _MetaChips extends StatelessWidget {
|
||||
'hard' => 'Сложно',
|
||||
_ => d,
|
||||
};
|
||||
|
||||
String _cuisineLabel(String c) => switch (c) {
|
||||
'russian' => 'Русская',
|
||||
'asian' => 'Азиатская',
|
||||
'european' => 'Европейская',
|
||||
'mediterranean' => 'Средиземноморская',
|
||||
'american' => 'Американская',
|
||||
_ => 'Другая',
|
||||
};
|
||||
}
|
||||
|
||||
class _Chip extends StatelessWidget {
|
||||
@@ -347,20 +343,24 @@ class _NutCell extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
class _TagsRow extends StatelessWidget {
|
||||
class _TagsRow extends ConsumerWidget {
|
||||
final List<String> tags;
|
||||
|
||||
const _TagsRow({required this.tags});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final tagNames = ref.watch(tagNamesProvider).valueOrNull ?? {};
|
||||
return Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
children: tags
|
||||
.map(
|
||||
(t) => Chip(
|
||||
label: Text(t, style: const TextStyle(fontSize: 11)),
|
||||
label: Text(
|
||||
tagNames[t] ?? t,
|
||||
style: const TextStyle(fontSize: 11),
|
||||
),
|
||||
backgroundColor: AppColors.primary.withValues(alpha: 0.15),
|
||||
padding: EdgeInsets.zero,
|
||||
visualDensity: VisualDensity.compact,
|
||||
@@ -402,7 +402,7 @@ class _IngredientsSection extends ConsumerWidget {
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: Text(ing.name)),
|
||||
Text(
|
||||
'${_formatAmount(ing.amount)} ${ref.watch(unitsProvider).valueOrNull?[ing.unit] ?? ing.unit}',
|
||||
'${_formatAmount(ing.amount)} ${ref.watch(unitsProvider).valueOrNull?[ing.effectiveUnit] ?? ing.effectiveUnit}',
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary, fontSize: 13),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user