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>
453 lines
14 KiB
Dart
453 lines
14 KiB
Dart
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),
|
||
);
|
||
}
|