Files
food-ai/client/lib/features/home/home_screen.dart
dbastrikin 9530dc6ff9 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>
2026-02-22 15:25:28 +02:00

453 lines
14 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
import '../../shared/models/home_summary.dart';
import 'home_provider.dart';
class HomeScreen extends ConsumerWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(homeProvider);
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),
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),
);
}