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:
dbastrikin
2026-02-22 15:25:28 +02:00
parent d53e019d90
commit 9530dc6ff9
8 changed files with 899 additions and 4 deletions

View 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)),
);

View File

@@ -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<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),
);
}

View 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);
}
}