feat: implement Iteration 5 — home screen dashboard
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 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@ import (
|
|||||||
"github.com/food-ai/backend/internal/database"
|
"github.com/food-ai/backend/internal/database"
|
||||||
"github.com/food-ai/backend/internal/diary"
|
"github.com/food-ai/backend/internal/diary"
|
||||||
"github.com/food-ai/backend/internal/gemini"
|
"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/ingredient"
|
||||||
"github.com/food-ai/backend/internal/menu"
|
"github.com/food-ai/backend/internal/menu"
|
||||||
"github.com/food-ai/backend/internal/middleware"
|
"github.com/food-ai/backend/internal/middleware"
|
||||||
@@ -122,6 +123,9 @@ func run() error {
|
|||||||
diaryRepo := diary.NewRepository(pool)
|
diaryRepo := diary.NewRepository(pool)
|
||||||
diaryHandler := diary.NewHandler(diaryRepo)
|
diaryHandler := diary.NewHandler(diaryRepo)
|
||||||
|
|
||||||
|
// Home domain
|
||||||
|
homeHandler := home.NewHandler(pool)
|
||||||
|
|
||||||
// Router
|
// Router
|
||||||
router := server.NewRouter(
|
router := server.NewRouter(
|
||||||
pool,
|
pool,
|
||||||
@@ -134,6 +138,7 @@ func run() error {
|
|||||||
recognitionHandler,
|
recognitionHandler,
|
||||||
menuHandler,
|
menuHandler,
|
||||||
diaryHandler,
|
diaryHandler,
|
||||||
|
homeHandler,
|
||||||
authMW,
|
authMW,
|
||||||
cfg.AllowedOrigins,
|
cfg.AllowedOrigins,
|
||||||
)
|
)
|
||||||
|
|||||||
238
backend/internal/home/handler.go
Normal file
238
backend/internal/home/handler.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
39
backend/internal/home/model.go
Normal file
39
backend/internal/home/model.go
Normal file
@@ -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"`
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/food-ai/backend/internal/auth"
|
"github.com/food-ai/backend/internal/auth"
|
||||||
"github.com/food-ai/backend/internal/diary"
|
"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/ingredient"
|
||||||
"github.com/food-ai/backend/internal/menu"
|
"github.com/food-ai/backend/internal/menu"
|
||||||
"github.com/food-ai/backend/internal/middleware"
|
"github.com/food-ai/backend/internal/middleware"
|
||||||
@@ -29,6 +30,7 @@ func NewRouter(
|
|||||||
recognitionHandler *recognition.Handler,
|
recognitionHandler *recognition.Handler,
|
||||||
menuHandler *menu.Handler,
|
menuHandler *menu.Handler,
|
||||||
diaryHandler *diary.Handler,
|
diaryHandler *diary.Handler,
|
||||||
|
homeHandler *home.Handler,
|
||||||
authMiddleware func(http.Handler) http.Handler,
|
authMiddleware func(http.Handler) http.Handler,
|
||||||
allowedOrigins []string,
|
allowedOrigins []string,
|
||||||
) *chi.Mux {
|
) *chi.Mux {
|
||||||
@@ -92,6 +94,8 @@ func NewRouter(
|
|||||||
r.Delete("/{id}", diaryHandler.Delete)
|
r.Delete("/{id}", diaryHandler.Delete)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
r.Get("/home/summary", homeHandler.GetSummary)
|
||||||
|
|
||||||
r.Route("/ai", func(r chi.Router) {
|
r.Route("/ai", func(r chi.Router) {
|
||||||
r.Post("/recognize-receipt", recognitionHandler.RecognizeReceipt)
|
r.Post("/recognize-receipt", recognitionHandler.RecognizeReceipt)
|
||||||
r.Post("/recognize-products", recognitionHandler.RecognizeProducts)
|
r.Post("/recognize-products", recognitionHandler.RecognizeProducts)
|
||||||
|
|||||||
22
client/lib/features/home/home_provider.dart
Normal file
22
client/lib/features/home/home_provider.dart
Normal file
@@ -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<AsyncValue<HomeSummary>> {
|
||||||
|
final HomeService _service;
|
||||||
|
|
||||||
|
HomeNotifier(this._service) : super(const AsyncValue.loading()) {
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> load() async {
|
||||||
|
state = const AsyncValue.loading();
|
||||||
|
state = await AsyncValue.guard(() => _service.getSummary());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final homeProvider =
|
||||||
|
StateNotifierProvider<HomeNotifier, AsyncValue<HomeSummary>>(
|
||||||
|
(ref) => HomeNotifier(ref.read(homeServiceProvider)),
|
||||||
|
);
|
||||||
@@ -1,13 +1,452 @@
|
|||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.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});
|
const HomeScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final state = ref.watch(homeProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Главная')),
|
body: state.when(
|
||||||
body: const Center(child: Text('Раздел в разработке')),
|
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<TodayMealPlan> 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<ExpiringSoon> 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<HomeRecipe> 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
19
client/lib/features/home/home_service.dart
Normal file
19
client/lib/features/home/home_service.dart
Normal file
@@ -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<HomeService>((ref) {
|
||||||
|
return HomeService(ref.read(apiClientProvider));
|
||||||
|
});
|
||||||
|
|
||||||
|
class HomeService {
|
||||||
|
final ApiClient _client;
|
||||||
|
HomeService(this._client);
|
||||||
|
|
||||||
|
Future<HomeSummary> getSummary() async {
|
||||||
|
final json = await _client.get('/home/summary');
|
||||||
|
return HomeSummary.fromJson(json);
|
||||||
|
}
|
||||||
|
}
|
||||||
129
client/lib/shared/models/home_summary.dart
Normal file
129
client/lib/shared/models/home_summary.dart
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
class HomeSummary {
|
||||||
|
final TodaySummary today;
|
||||||
|
final List<ExpiringSoon> expiringSoon;
|
||||||
|
final List<HomeRecipe> recommendations;
|
||||||
|
|
||||||
|
const HomeSummary({
|
||||||
|
required this.today,
|
||||||
|
required this.expiringSoon,
|
||||||
|
required this.recommendations,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory HomeSummary.fromJson(Map<String, dynamic> json) => HomeSummary(
|
||||||
|
today: TodaySummary.fromJson(json['today'] as Map<String, dynamic>),
|
||||||
|
expiringSoon: (json['expiring_soon'] as List<dynamic>? ?? [])
|
||||||
|
.map((e) => ExpiringSoon.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
recommendations: (json['recommendations'] as List<dynamic>? ?? [])
|
||||||
|
.map((e) => HomeRecipe.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class TodaySummary {
|
||||||
|
final String date;
|
||||||
|
final int dailyGoal;
|
||||||
|
final double loggedCalories;
|
||||||
|
final List<TodayMealPlan> plan;
|
||||||
|
|
||||||
|
const TodaySummary({
|
||||||
|
required this.date,
|
||||||
|
required this.dailyGoal,
|
||||||
|
required this.loggedCalories,
|
||||||
|
required this.plan,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory TodaySummary.fromJson(Map<String, dynamic> 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<dynamic>? ?? [])
|
||||||
|
.map((e) => TodayMealPlan.fromJson(e as Map<String, dynamic>))
|
||||||
|
.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<String, dynamic> 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<String, dynamic> 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<String, dynamic> json) => HomeRecipe(
|
||||||
|
id: json['id'] as String,
|
||||||
|
title: json['title'] as String,
|
||||||
|
imageUrl: json['image_url'] as String? ?? '',
|
||||||
|
calories: (json['calories'] as num?)?.toDouble(),
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user