From 9530dc6ff9057aa8188893029851356cfcb8faba Mon Sep 17 00:00:00 2001 From: dbastrikin Date: Sun, 22 Feb 2026 15:25:28 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20implement=20Iteration=205=20=E2=80=94?= =?UTF-8?q?=20home=20screen=20dashboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - internal/home: GET /home/summary endpoint - Today's meal plan from menu_plans/menu_items - Logged calories sum from meal_diary - Daily goal from user profile (default 2000) - Expiring products within 3 days - Last 3 saved recommendations (no AI call on home load) - Wire homeHandler in server.go and main.go Flutter: - shared/models/home_summary.dart: HomeSummary, TodaySummary, TodayMealPlan, ExpiringSoon, HomeRecipe - features/home/home_service.dart + home_provider.dart - features/home/home_screen.dart: greeting, calorie progress bar, today's meals card, expiring banner, quick actions row, recommendations horizontal list Co-Authored-By: Claude Sonnet 4.6 --- backend/cmd/server/main.go | 5 + backend/internal/home/handler.go | 238 +++++++++++ backend/internal/home/model.go | 39 ++ backend/internal/server/server.go | 4 + client/lib/features/home/home_provider.dart | 22 + client/lib/features/home/home_screen.dart | 447 +++++++++++++++++++- client/lib/features/home/home_service.dart | 19 + client/lib/shared/models/home_summary.dart | 129 ++++++ 8 files changed, 899 insertions(+), 4 deletions(-) create mode 100644 backend/internal/home/handler.go create mode 100644 backend/internal/home/model.go create mode 100644 client/lib/features/home/home_provider.dart create mode 100644 client/lib/features/home/home_service.dart create mode 100644 client/lib/shared/models/home_summary.dart diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index ca9c297..90db562 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -15,6 +15,7 @@ import ( "github.com/food-ai/backend/internal/database" "github.com/food-ai/backend/internal/diary" "github.com/food-ai/backend/internal/gemini" + "github.com/food-ai/backend/internal/home" "github.com/food-ai/backend/internal/ingredient" "github.com/food-ai/backend/internal/menu" "github.com/food-ai/backend/internal/middleware" @@ -122,6 +123,9 @@ func run() error { diaryRepo := diary.NewRepository(pool) diaryHandler := diary.NewHandler(diaryRepo) + // Home domain + homeHandler := home.NewHandler(pool) + // Router router := server.NewRouter( pool, @@ -134,6 +138,7 @@ func run() error { recognitionHandler, menuHandler, diaryHandler, + homeHandler, authMW, cfg.AllowedOrigins, ) diff --git a/backend/internal/home/handler.go b/backend/internal/home/handler.go new file mode 100644 index 0000000..1592dac --- /dev/null +++ b/backend/internal/home/handler.go @@ -0,0 +1,238 @@ +package home + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "time" + + "github.com/food-ai/backend/internal/middleware" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Handler handles GET /home/summary. +type Handler struct { + pool *pgxpool.Pool +} + +// NewHandler creates a new Handler. +func NewHandler(pool *pgxpool.Pool) *Handler { + return &Handler{pool: pool} +} + +// GetSummary handles GET /home/summary. +func (h *Handler) GetSummary(w http.ResponseWriter, r *http.Request) { + userID := middleware.UserIDFromCtx(r.Context()) + if userID == "" { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + + ctx := r.Context() + now := time.Now().UTC() + todayStr := now.Format("2006-01-02") + + // ISO day of week: Monday=1 … Sunday=7. + wd := int(now.Weekday()) + if wd == 0 { + wd = 7 + } + + // Monday of the current ISO week. + year, week := now.ISOWeek() + weekStart := mondayOfISOWeek(year, week).Format("2006-01-02") + + // Daily calorie goal from user profile. + dailyGoal := h.getDailyGoal(ctx, userID) + + summary := Summary{ + Today: TodaySummary{ + Date: todayStr, + DailyGoal: dailyGoal, + LoggedCalories: h.getLoggedCalories(ctx, userID, todayStr), + Plan: h.getTodayPlan(ctx, userID, weekStart, wd), + }, + ExpiringSoon: h.getExpiringSoon(ctx, userID), + Recommendations: h.getRecommendations(ctx, userID), + } + + writeJSON(w, http.StatusOK, summary) +} + +// getDailyGoal returns the user's daily_calories setting (default 2000). +func (h *Handler) getDailyGoal(ctx context.Context, userID string) int { + var goal int + err := h.pool.QueryRow(ctx, + `SELECT COALESCE(daily_calories, 2000) FROM users WHERE id = $1`, + userID, + ).Scan(&goal) + if err != nil { + slog.Warn("home: get daily goal", "user_id", userID, "err", err) + return 2000 + } + return goal +} + +// getLoggedCalories returns total calories logged in meal_diary for today. +func (h *Handler) getLoggedCalories(ctx context.Context, userID, date string) float64 { + var total float64 + _ = h.pool.QueryRow(ctx, + `SELECT COALESCE(SUM(calories * portions), 0) + FROM meal_diary + WHERE user_id = $1 AND date::text = $2`, + userID, date, + ).Scan(&total) + return total +} + +// getTodayPlan returns the three meal slots planned for today. +// If no menu exists, all three slots are returned with nil recipe fields. +func (h *Handler) getTodayPlan(ctx context.Context, userID, weekStart string, dow int) []MealPlan { + const q = ` + SELECT mi.meal_type, + sr.title, + sr.image_url, + (sr.nutrition->>'calories')::float + FROM menu_plans mp + JOIN menu_items mi ON mi.menu_plan_id = mp.id + LEFT JOIN saved_recipes sr ON sr.id = mi.recipe_id + WHERE mp.user_id = $1 + AND mp.week_start::text = $2 + AND mi.day_of_week = $3` + + rows, err := h.pool.Query(ctx, q, userID, weekStart, dow) + if err != nil { + slog.Warn("home: get today plan", "err", err) + return defaultPlan() + } + defer rows.Close() + + found := map[string]MealPlan{} + for rows.Next() { + var mealType string + var title, imageURL *string + var calories *float64 + if err := rows.Scan(&mealType, &title, &imageURL, &calories); err != nil { + continue + } + found[mealType] = MealPlan{ + MealType: mealType, + RecipeTitle: title, + RecipeImageURL: imageURL, + Calories: calories, + } + } + + // Always return all three meal types in order. + mealTypes := []string{"breakfast", "lunch", "dinner"} + plan := make([]MealPlan, 0, 3) + for _, mt := range mealTypes { + if mp, ok := found[mt]; ok { + plan = append(plan, mp) + } else { + plan = append(plan, MealPlan{MealType: mt}) + } + } + return plan +} + +// defaultPlan returns three empty meal slots. +func defaultPlan() []MealPlan { + return []MealPlan{ + {MealType: "breakfast"}, + {MealType: "lunch"}, + {MealType: "dinner"}, + } +} + +// getExpiringSoon returns products expiring within the next 3 days. +func (h *Handler) getExpiringSoon(ctx context.Context, userID string) []ExpiringSoon { + rows, err := h.pool.Query(ctx, ` + SELECT name, quantity, unit, + GREATEST(0, EXTRACT(EPOCH FROM (expires_at - now())) / 86400)::int + FROM products + WHERE user_id = $1 + AND expires_at IS NOT NULL + AND expires_at > now() + AND expires_at <= now() + INTERVAL '3 days' + ORDER BY expires_at + LIMIT 5`, + userID, + ) + if err != nil { + slog.Warn("home: get expiring soon", "err", err) + return nil + } + defer rows.Close() + + var result []ExpiringSoon + for rows.Next() { + var name, unit string + var quantity float64 + var daysLeft int + if err := rows.Scan(&name, &quantity, &unit, &daysLeft); err != nil { + continue + } + result = append(result, ExpiringSoon{ + Name: name, + ExpiresInDays: daysLeft, + Quantity: fmt.Sprintf("%.0f %s", quantity, unit), + }) + } + return result +} + +// getRecommendations returns the 3 most recently generated recipe recommendations. +func (h *Handler) getRecommendations(ctx context.Context, userID string) []Recommendation { + rows, err := h.pool.Query(ctx, ` + SELECT id, title, COALESCE(image_url, ''), + (nutrition->>'calories')::float + FROM saved_recipes + WHERE user_id = $1 AND source = 'recommendation' + ORDER BY saved_at DESC + LIMIT 3`, + userID, + ) + if err != nil { + slog.Warn("home: get recommendations", "err", err) + return nil + } + defer rows.Close() + + var result []Recommendation + for rows.Next() { + var rec Recommendation + var cal *float64 + if err := rows.Scan(&rec.ID, &rec.Title, &rec.ImageURL, &cal); err != nil { + continue + } + rec.Calories = cal + result = append(result, rec) + } + return result +} + +// mondayOfISOWeek returns the Monday of the given ISO year/week. +func mondayOfISOWeek(year, week int) time.Time { + jan4 := time.Date(year, time.January, 4, 0, 0, 0, 0, time.UTC) + wd := int(jan4.Weekday()) + if wd == 0 { + wd = 7 + } + monday1 := jan4.AddDate(0, 0, 1-wd) + return monday1.AddDate(0, 0, (week-1)*7) +} + +func writeError(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} diff --git a/backend/internal/home/model.go b/backend/internal/home/model.go new file mode 100644 index 0000000..62cacaf --- /dev/null +++ b/backend/internal/home/model.go @@ -0,0 +1,39 @@ +package home + +// Summary is the response for GET /home/summary. +type Summary struct { + Today TodaySummary `json:"today"` + ExpiringSoon []ExpiringSoon `json:"expiring_soon"` + Recommendations []Recommendation `json:"recommendations"` +} + +// TodaySummary contains the day-level overview. +type TodaySummary struct { + Date string `json:"date"` + DailyGoal int `json:"daily_goal"` + LoggedCalories float64 `json:"logged_calories"` + Plan []MealPlan `json:"plan"` +} + +// MealPlan is a single planned meal slot for today. +type MealPlan struct { + MealType string `json:"meal_type"` + RecipeTitle *string `json:"recipe_title"` + RecipeImageURL *string `json:"recipe_image_url"` + Calories *float64 `json:"calories"` +} + +// ExpiringSoon is a product expiring within 3 days. +type ExpiringSoon struct { + Name string `json:"name"` + ExpiresInDays int `json:"expires_in_days"` + Quantity string `json:"quantity"` +} + +// Recommendation is a saved recipe shown on the home screen. +type Recommendation struct { + ID string `json:"id"` + Title string `json:"title"` + ImageURL string `json:"image_url"` + Calories *float64 `json:"calories"` +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index d39aa0c..e7a2720 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -6,6 +6,7 @@ import ( "github.com/food-ai/backend/internal/auth" "github.com/food-ai/backend/internal/diary" + "github.com/food-ai/backend/internal/home" "github.com/food-ai/backend/internal/ingredient" "github.com/food-ai/backend/internal/menu" "github.com/food-ai/backend/internal/middleware" @@ -29,6 +30,7 @@ func NewRouter( recognitionHandler *recognition.Handler, menuHandler *menu.Handler, diaryHandler *diary.Handler, + homeHandler *home.Handler, authMiddleware func(http.Handler) http.Handler, allowedOrigins []string, ) *chi.Mux { @@ -92,6 +94,8 @@ func NewRouter( r.Delete("/{id}", diaryHandler.Delete) }) + r.Get("/home/summary", homeHandler.GetSummary) + r.Route("/ai", func(r chi.Router) { r.Post("/recognize-receipt", recognitionHandler.RecognizeReceipt) r.Post("/recognize-products", recognitionHandler.RecognizeProducts) diff --git a/client/lib/features/home/home_provider.dart b/client/lib/features/home/home_provider.dart new file mode 100644 index 0000000..b4adc74 --- /dev/null +++ b/client/lib/features/home/home_provider.dart @@ -0,0 +1,22 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../shared/models/home_summary.dart'; +import 'home_service.dart'; + +class HomeNotifier extends StateNotifier> { + final HomeService _service; + + HomeNotifier(this._service) : super(const AsyncValue.loading()) { + load(); + } + + Future load() async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() => _service.getSummary()); + } +} + +final homeProvider = + StateNotifierProvider>( + (ref) => HomeNotifier(ref.read(homeServiceProvider)), +); diff --git a/client/lib/features/home/home_screen.dart b/client/lib/features/home/home_screen.dart index 14f9fb6..509abdb 100644 --- a/client/lib/features/home/home_screen.dart +++ b/client/lib/features/home/home_screen.dart @@ -1,13 +1,452 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; -class HomeScreen extends StatelessWidget { +import '../../shared/models/home_summary.dart'; +import 'home_provider.dart'; + +class HomeScreen extends ConsumerWidget { const HomeScreen({super.key}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(homeProvider); + return Scaffold( - appBar: AppBar(title: const Text('Главная')), - body: const Center(child: Text('Раздел в разработке')), + 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), + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 100), + sliver: SliverList( + delegate: SliverChildListDelegate([ + const SizedBox(height: 16), + _CaloriesCard(today: summary.today), + 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), + ], + ]), + ), + ), + ], + ), + ), + ), ); } } + +// ── App bar ─────────────────────────────────────────────────── + +class _AppBar extends StatelessWidget { + final HomeSummary summary; + const _AppBar({required this.summary}); + + String get _greeting { + final hour = DateTime.now().hour; + if (hour < 12) return 'Доброе утро'; + if (hour < 18) return 'Добрый день'; + return 'Добрый вечер'; + } + + 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), + ), + ], + ), + ); + } +} + +// ── Calories card ───────────────────────────────────────────── + +class _CaloriesCard extends StatelessWidget { + final TodaySummary today; + const _CaloriesCard({required this.today}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + if (today.dailyGoal == 0) return const SizedBox.shrink(); + + final logged = today.loggedCalories.toInt(); + final goal = today.dailyGoal; + final remaining = today.remainingCalories.toInt(); + final progress = today.progress; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Калории сегодня', style: theme.textTheme.titleSmall), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '$logged ккал', + style: theme.textTheme.headlineSmall + ?.copyWith(fontWeight: FontWeight.bold), + ), + Text( + 'из $goal ккал', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + Text( + 'осталось $remaining', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(height: 10), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: progress, + minHeight: 8, + backgroundColor: theme.colorScheme.surfaceContainerHighest, + ), + ), + ], + ), + ), + ); + } +} + +// ── Today meals card ────────────────────────────────────────── + +class _TodayMealsCard extends StatelessWidget { + final List plan; + const _TodayMealsCard({required this.plan}); + + @override + Widget build(BuildContext context) { + 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(), + ), + ), + ], + ); + } +} + +class _MealRow extends StatelessWidget { + final TodayMealPlan meal; + const _MealRow({required this.meal}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + 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( + 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'), + ); + } +} + +// ── Expiring banner ─────────────────────────────────────────── + +class _ExpiringBanner extends StatelessWidget { + final List items; + const _ExpiringBanner({required this.items}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final color = theme.colorScheme.errorContainer; + final onColor = theme.colorScheme.onErrorContainer; + + return GestureDetector( + onTap: () => context.push('/products'), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Icons.warning_amber_rounded, color: onColor, size: 20), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Истекает срок годности', + style: theme.textTheme.labelMedium + ?.copyWith(color: onColor, fontWeight: FontWeight.w600), + ), + Text( + items + .take(3) + .map((e) => '${e.name} — ${e.expiryLabel}') + .join(', '), + style: theme.textTheme.bodySmall?.copyWith(color: onColor), + ), + ], + ), + ), + Icon(Icons.chevron_right, color: onColor), + ], + ), + ), + ); + } +} + +// ── Quick actions ───────────────────────────────────────────── + +class _QuickActionsRow extends StatelessWidget { + final String date; + const _QuickActionsRow({required this.date}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: _ActionButton( + icon: Icons.document_scanner_outlined, + label: 'Сканировать', + onTap: () => context.push('/scan'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _ActionButton( + icon: Icons.calendar_month_outlined, + label: 'Меню', + onTap: () => context.push('/menu'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _ActionButton( + icon: Icons.book_outlined, + label: 'Дневник', + onTap: () => context.push('/menu/diary', extra: date), + ), + ), + ], + ); + } +} + +class _ActionButton extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback onTap; + const _ActionButton( + {required this.icon, required this.label, required this.onTap}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 14), + child: Column( + children: [ + Icon(icon, size: 24), + const SizedBox(height: 6), + Text(label, + style: theme.textTheme.labelSmall, + textAlign: TextAlign.center), + ], + ), + ), + ), + ); + } +} + +// ── Section title ───────────────────────────────────────────── + +class _SectionTitle extends StatelessWidget { + final String text; + const _SectionTitle(this.text); + + @override + Widget build(BuildContext context) => + Text(text, style: Theme.of(context).textTheme.titleSmall); +} + +// ── Recommendations row ─────────────────────────────────────── + +class _RecommendationsRow extends StatelessWidget { + final List recipes; + const _RecommendationsRow({required this.recipes}); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 168, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: recipes.length, + separatorBuilder: (_, __) => const SizedBox(width: 12), + itemBuilder: (context, index) => + _RecipeCard(recipe: recipes[index]), + ), + ); + } +} + +class _RecipeCard extends StatelessWidget { + final HomeRecipe recipe; + const _RecipeCard({required this.recipe}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return SizedBox( + width: 140, + child: Card( + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () {}, // recipes detail can be added later + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + recipe.imageUrl.isNotEmpty + ? CachedNetworkImage( + imageUrl: recipe.imageUrl, + height: 96, + width: double.infinity, + fit: BoxFit.cover, + errorWidget: (_, __, ___) => _imagePlaceholder(), + ) + : _imagePlaceholder(), + Padding( + padding: const EdgeInsets.fromLTRB(8, 6, 8, 4), + child: Text( + recipe.title, + style: theme.textTheme.bodySmall + ?.copyWith(fontWeight: FontWeight.w600), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + if (recipe.calories != null) + Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 6), + child: Text( + '≈${recipe.calories!.toInt()} ккал', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _imagePlaceholder() => Container( + height: 96, + color: Colors.grey.shade200, + child: const Icon(Icons.restaurant, color: Colors.grey), + ); +} diff --git a/client/lib/features/home/home_service.dart b/client/lib/features/home/home_service.dart new file mode 100644 index 0000000..36baa86 --- /dev/null +++ b/client/lib/features/home/home_service.dart @@ -0,0 +1,19 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/api/api_client.dart'; +import '../../core/auth/auth_provider.dart'; +import '../../shared/models/home_summary.dart'; + +final homeServiceProvider = Provider((ref) { + return HomeService(ref.read(apiClientProvider)); +}); + +class HomeService { + final ApiClient _client; + HomeService(this._client); + + Future getSummary() async { + final json = await _client.get('/home/summary'); + return HomeSummary.fromJson(json); + } +} diff --git a/client/lib/shared/models/home_summary.dart b/client/lib/shared/models/home_summary.dart new file mode 100644 index 0000000..0ab4f1b --- /dev/null +++ b/client/lib/shared/models/home_summary.dart @@ -0,0 +1,129 @@ +class HomeSummary { + final TodaySummary today; + final List expiringSoon; + final List recommendations; + + const HomeSummary({ + required this.today, + required this.expiringSoon, + required this.recommendations, + }); + + factory HomeSummary.fromJson(Map json) => HomeSummary( + today: TodaySummary.fromJson(json['today'] as Map), + expiringSoon: (json['expiring_soon'] as List? ?? []) + .map((e) => ExpiringSoon.fromJson(e as Map)) + .toList(), + recommendations: (json['recommendations'] as List? ?? []) + .map((e) => HomeRecipe.fromJson(e as Map)) + .toList(), + ); +} + +class TodaySummary { + final String date; + final int dailyGoal; + final double loggedCalories; + final List plan; + + const TodaySummary({ + required this.date, + required this.dailyGoal, + required this.loggedCalories, + required this.plan, + }); + + factory TodaySummary.fromJson(Map json) => TodaySummary( + date: json['date'] as String, + dailyGoal: (json['daily_goal'] as num).toInt(), + loggedCalories: (json['logged_calories'] as num).toDouble(), + plan: (json['plan'] as List? ?? []) + .map((e) => TodayMealPlan.fromJson(e as Map)) + .toList(), + ); + + double get remainingCalories => + dailyGoal > 0 ? (dailyGoal - loggedCalories).clamp(0, dailyGoal.toDouble()) : 0; + + double get progress => + dailyGoal > 0 ? (loggedCalories / dailyGoal).clamp(0.0, 1.0) : 0; +} + +class TodayMealPlan { + final String mealType; + final String? recipeTitle; + final String? recipeImageUrl; + final double? calories; + + const TodayMealPlan({ + required this.mealType, + this.recipeTitle, + this.recipeImageUrl, + this.calories, + }); + + factory TodayMealPlan.fromJson(Map json) => TodayMealPlan( + mealType: json['meal_type'] as String, + recipeTitle: json['recipe_title'] as String?, + recipeImageUrl: json['recipe_image_url'] as String?, + calories: (json['calories'] as num?)?.toDouble(), + ); + + bool get hasRecipe => recipeTitle != null; + + String get mealLabel => switch (mealType) { + 'breakfast' => 'Завтрак', + 'lunch' => 'Обед', + 'dinner' => 'Ужин', + _ => mealType, + }; + + String get mealEmoji => switch (mealType) { + 'breakfast' => '🌅', + 'lunch' => '☀️', + 'dinner' => '🌙', + _ => '🍽️', + }; +} + +class ExpiringSoon { + final String name; + final int expiresInDays; + final String quantity; + + const ExpiringSoon({ + required this.name, + required this.expiresInDays, + required this.quantity, + }); + + factory ExpiringSoon.fromJson(Map json) => ExpiringSoon( + name: json['name'] as String, + expiresInDays: (json['expires_in_days'] as num).toInt(), + quantity: json['quantity'] as String, + ); + + String get expiryLabel => + expiresInDays == 0 ? 'сегодня' : 'через $expiresInDays д.'; +} + +class HomeRecipe { + final String id; + final String title; + final String imageUrl; + final double? calories; + + const HomeRecipe({ + required this.id, + required this.title, + required this.imageUrl, + this.calories, + }); + + factory HomeRecipe.fromJson(Map json) => HomeRecipe( + id: json['id'] as String, + title: json['title'] as String, + imageUrl: json['image_url'] as String? ?? '', + calories: (json['calories'] as num?)?.toDouble(), + ); +}