Backend fixes: - migration 003: add 'menu' value to recipe_source enum (was causing SQLSTATE 22P02) - migration 004: rename recipe_products→recipe_ingredients, product_id→ingredient_id (was causing SQLSTATE 42P01) - dish/repository.go: fix INSERT INTO tags using $1/$1 for two columns → $1/$2 (was causing SQLSTATE 42P08) - home/handler.go: replace non-existent saved_recipes table with correct joins (recipes→dishes→dish_translations, user_saved_recipes) so today's plan and recommendations load correctly - reqlog: new slog.Handler wrapper that adds request_id and stack trace to ERROR-level logs - all handlers: slog.Error→slog.ErrorContext so error logs include request context; writeError includes request_id in response body Client: - home_screen.dart: extend home screen to future dates, show planned meals as ghost entries - l10n: add new localisation keys for home screen date navigation and planned meal UI Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1735 lines
56 KiB
Dart
1735 lines
56 KiB
Dart
import 'dart:math' as math;
|
||
|
||
import 'package:cached_network_image/cached_network_image.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:food_ai/l10n/app_localizations.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:go_router/go_router.dart';
|
||
import 'package:image_picker/image_picker.dart';
|
||
import 'package:intl/intl.dart';
|
||
|
||
import '../../core/storage/local_preferences_provider.dart';
|
||
import '../../core/theme/app_colors.dart';
|
||
import '../../shared/models/diary_entry.dart';
|
||
import '../../shared/models/home_summary.dart';
|
||
import '../../shared/models/meal_type.dart';
|
||
import '../../shared/models/menu.dart';
|
||
import '../diary/food_search_sheet.dart';
|
||
import '../menu/menu_provider.dart';
|
||
import '../profile/profile_provider.dart';
|
||
import '../scan/dish_result_screen.dart';
|
||
import '../scan/recognition_service.dart';
|
||
import 'home_provider.dart';
|
||
|
||
// ── Date context ───────────────────────────────────────────────
|
||
|
||
enum _DateContext { past, today, future }
|
||
|
||
_DateContext _contextFor(DateTime selected) {
|
||
final now = DateTime.now();
|
||
final todayNormalized = DateTime(now.year, now.month, now.day);
|
||
final selectedNormalized =
|
||
DateTime(selected.year, selected.month, selected.day);
|
||
if (selectedNormalized.isBefore(todayNormalized)) return _DateContext.past;
|
||
if (selectedNormalized.isAtSameMomentAs(todayNormalized)) {
|
||
return _DateContext.today;
|
||
}
|
||
return _DateContext.future;
|
||
}
|
||
|
||
// ── Root screen ───────────────────────────────────────────────
|
||
|
||
class HomeScreen extends ConsumerWidget {
|
||
const HomeScreen({super.key});
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final l10n = AppLocalizations.of(context)!;
|
||
final homeSummaryState = ref.watch(homeProvider);
|
||
final profile = ref.watch(profileProvider).valueOrNull;
|
||
final userName = profile?.name;
|
||
final goalType = profile?.goal;
|
||
final dailyGoal = profile?.dailyCalories ?? 0;
|
||
final userMealTypes = profile?.mealTypes ?? const ['breakfast', 'lunch', 'dinner'];
|
||
|
||
final selectedDate = ref.watch(selectedDateProvider);
|
||
final dateString = formatDateForDiary(selectedDate);
|
||
final dateContext = _contextFor(selectedDate);
|
||
final isFutureDate = dateContext == _DateContext.future;
|
||
|
||
final diaryState = ref.watch(diaryProvider(dateString));
|
||
final entries = diaryState.valueOrNull ?? [];
|
||
|
||
final loggedCalories = entries.fold<double>(
|
||
0.0, (sum, entry) => sum + (entry.calories ?? 0));
|
||
final loggedProtein = entries.fold<double>(
|
||
0.0, (sum, entry) => sum + (entry.proteinG ?? 0));
|
||
final loggedFat = entries.fold<double>(
|
||
0.0, (sum, entry) => sum + (entry.fatG ?? 0));
|
||
final loggedCarbs = entries.fold<double>(
|
||
0.0, (sum, entry) => sum + (entry.carbsG ?? 0));
|
||
|
||
final expiringSoon = homeSummaryState.valueOrNull?.expiringSoon ?? [];
|
||
final recommendations = homeSummaryState.valueOrNull?.recommendations ?? [];
|
||
|
||
final todayJobs = ref.watch(todayJobsProvider).valueOrNull ?? [];
|
||
|
||
return Scaffold(
|
||
body: RefreshIndicator(
|
||
onRefresh: () async {
|
||
ref.read(homeProvider.notifier).load();
|
||
ref.invalidate(diaryProvider(dateString));
|
||
ref.invalidate(todayJobsProvider);
|
||
},
|
||
child: CustomScrollView(
|
||
slivers: [
|
||
_AppBar(userName: userName),
|
||
SliverPadding(
|
||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 100),
|
||
sliver: SliverList(
|
||
delegate: SliverChildListDelegate([
|
||
const SizedBox(height: 12),
|
||
_DateSelector(
|
||
selectedDate: selectedDate,
|
||
onDateSelected: (date) =>
|
||
ref.read(selectedDateProvider.notifier).state = date,
|
||
),
|
||
const SizedBox(height: 16),
|
||
if (isFutureDate)
|
||
_FutureDayHeader(dateString: dateString)
|
||
else ...[
|
||
_CaloriesCard(
|
||
loggedCalories: loggedCalories,
|
||
dailyGoal: dailyGoal,
|
||
goalType: goalType,
|
||
),
|
||
const SizedBox(height: 12),
|
||
_MacrosRow(
|
||
proteinG: loggedProtein,
|
||
fatG: loggedFat,
|
||
carbsG: loggedCarbs,
|
||
),
|
||
],
|
||
if (!isFutureDate && todayJobs.isNotEmpty) ...[
|
||
const SizedBox(height: 16),
|
||
_TodayJobsWidget(jobs: todayJobs),
|
||
],
|
||
const SizedBox(height: 16),
|
||
_DailyMealsSection(
|
||
mealTypeIds: userMealTypes,
|
||
entries: entries,
|
||
dateString: dateString,
|
||
),
|
||
if (!isFutureDate && expiringSoon.isNotEmpty) ...[
|
||
const SizedBox(height: 16),
|
||
_ExpiringBanner(items: expiringSoon),
|
||
],
|
||
const SizedBox(height: 16),
|
||
_QuickActionsRow(),
|
||
if (!isFutureDate && recommendations.isNotEmpty) ...[
|
||
const SizedBox(height: 20),
|
||
_SectionTitle(l10n.recommendCook),
|
||
const SizedBox(height: 12),
|
||
_RecommendationsRow(recipes: recommendations),
|
||
],
|
||
]),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── App bar ───────────────────────────────────────────────────
|
||
|
||
class _AppBar extends StatelessWidget {
|
||
final String? userName;
|
||
const _AppBar({this.userName});
|
||
|
||
String _greetingBase(AppLocalizations l10n) {
|
||
final hour = DateTime.now().hour;
|
||
if (hour < 12) return l10n.greetingMorning;
|
||
if (hour < 18) return l10n.greetingAfternoon;
|
||
return l10n.greetingEvening;
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final l10n = AppLocalizations.of(context)!;
|
||
final theme = Theme.of(context);
|
||
final base = _greetingBase(l10n);
|
||
final greeting = (userName != null && userName!.isNotEmpty)
|
||
? '$base, $userName!'
|
||
: base;
|
||
return SliverAppBar(
|
||
pinned: false,
|
||
floating: true,
|
||
title: Text(greeting, style: theme.textTheme.titleMedium),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Date selector ─────────────────────────────────────────────
|
||
|
||
class _DateSelector extends StatefulWidget {
|
||
final DateTime selectedDate;
|
||
final ValueChanged<DateTime> onDateSelected;
|
||
|
||
const _DateSelector({
|
||
required this.selectedDate,
|
||
required this.onDateSelected,
|
||
});
|
||
|
||
@override
|
||
State<_DateSelector> createState() => _DateSelectorState();
|
||
}
|
||
|
||
class _DateSelectorState extends State<_DateSelector> {
|
||
// Strip covers 7 future days + today + 364 past days = 372 items total.
|
||
// With reverse: true, index 0 is rendered at the RIGHT edge (newest).
|
||
// index 0 = today + 7, index 7 = today, index 371 = today - 364.
|
||
static const _futureDays = 7;
|
||
static const _pastDays = 364;
|
||
static const _totalDays = _futureDays + 1 + _pastDays; // 372
|
||
static const _pillWidth = 48.0;
|
||
static const _pillSpacing = 6.0;
|
||
|
||
late final ScrollController _scrollController;
|
||
|
||
String _formatSelectedDate(DateTime date, String localeCode) {
|
||
final now = DateTime.now();
|
||
final yearSuffix = date.year != now.year ? ' ${date.year}' : '';
|
||
return DateFormat('EEE, d MMMM', localeCode).format(date) + yearSuffix;
|
||
}
|
||
|
||
// Maps a date to its index in the reversed ListView.
|
||
// today → _futureDays, tomorrow → _futureDays - 1, … , +7 → 0.
|
||
// yesterday → _futureDays + 1, … , -364 → _futureDays + 364.
|
||
int _indexForDate(DateTime date) {
|
||
final today = DateTime.now();
|
||
final todayNormalized = DateTime(today.year, today.month, today.day);
|
||
final dateNormalized = DateTime(date.year, date.month, date.day);
|
||
final daysFromToday =
|
||
dateNormalized.difference(todayNormalized).inDays;
|
||
return (_futureDays - daysFromToday).clamp(0, _totalDays - 1);
|
||
}
|
||
|
||
double _offsetForIndex(int index) => index * (_pillWidth + _pillSpacing);
|
||
|
||
void _selectPreviousDay() {
|
||
final previousDay = widget.selectedDate.subtract(const Duration(days: 1));
|
||
final previousDayNormalized =
|
||
DateTime(previousDay.year, previousDay.month, previousDay.day);
|
||
final today = DateTime.now();
|
||
final oldestAllowed = DateTime(today.year, today.month, today.day)
|
||
.subtract(const Duration(days: _pastDays));
|
||
if (!previousDayNormalized.isBefore(oldestAllowed)) {
|
||
widget.onDateSelected(previousDayNormalized);
|
||
}
|
||
}
|
||
|
||
void _selectNextDay() {
|
||
final today = DateTime.now();
|
||
final futureLimitDate = DateTime(today.year, today.month, today.day)
|
||
.add(const Duration(days: _futureDays));
|
||
final nextDay = widget.selectedDate.add(const Duration(days: 1));
|
||
final nextDayNormalized =
|
||
DateTime(nextDay.year, nextDay.month, nextDay.day);
|
||
if (!nextDayNormalized.isAfter(futureLimitDate)) {
|
||
widget.onDateSelected(nextDayNormalized);
|
||
}
|
||
}
|
||
|
||
void _jumpToToday() {
|
||
final today = DateTime.now();
|
||
widget.onDateSelected(DateTime(today.year, today.month, today.day));
|
||
if (_scrollController.hasClients) {
|
||
_scrollController.animateTo(
|
||
_offsetForIndex(_futureDays),
|
||
duration: const Duration(milliseconds: 300),
|
||
curve: Curves.easeInOut,
|
||
);
|
||
}
|
||
}
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_scrollController = ScrollController();
|
||
}
|
||
|
||
@override
|
||
void didUpdateWidget(_DateSelector oldWidget) {
|
||
super.didUpdateWidget(oldWidget);
|
||
final oldIndex = _indexForDate(oldWidget.selectedDate);
|
||
final newIndex = _indexForDate(widget.selectedDate);
|
||
if (oldIndex != newIndex && _scrollController.hasClients) {
|
||
_scrollController.animateTo(
|
||
_offsetForIndex(newIndex),
|
||
duration: const Duration(milliseconds: 300),
|
||
curve: Curves.easeInOut,
|
||
);
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_scrollController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final l10n = AppLocalizations.of(context)!;
|
||
final localeCode = Localizations.localeOf(context).toString();
|
||
final theme = Theme.of(context);
|
||
final today = DateTime.now();
|
||
final todayNormalized = DateTime(today.year, today.month, today.day);
|
||
final selectedNormalized = DateTime(
|
||
widget.selectedDate.year, widget.selectedDate.month, widget.selectedDate.day);
|
||
final isToday = selectedNormalized == todayNormalized;
|
||
final futureLimitDate =
|
||
todayNormalized.add(const Duration(days: _futureDays));
|
||
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// ── Header row ──────────────────────────────────────────
|
||
SizedBox(
|
||
height: 36,
|
||
child: Row(
|
||
children: [
|
||
IconButton(
|
||
icon: const Icon(Icons.chevron_left),
|
||
iconSize: 20,
|
||
visualDensity: VisualDensity.compact,
|
||
onPressed: _selectPreviousDay,
|
||
),
|
||
Expanded(
|
||
child: Text(
|
||
_formatSelectedDate(widget.selectedDate, localeCode),
|
||
style: theme.textTheme.labelMedium
|
||
?.copyWith(fontWeight: FontWeight.w600),
|
||
textAlign: TextAlign.center,
|
||
),
|
||
),
|
||
if (!isToday)
|
||
TextButton(
|
||
onPressed: _jumpToToday,
|
||
style: TextButton.styleFrom(
|
||
visualDensity: VisualDensity.compact,
|
||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||
),
|
||
child: Text(
|
||
l10n.today,
|
||
style: theme.textTheme.labelMedium
|
||
?.copyWith(color: theme.colorScheme.primary),
|
||
),
|
||
)
|
||
else
|
||
IconButton(
|
||
icon: const Icon(Icons.chevron_right),
|
||
iconSize: 20,
|
||
visualDensity: VisualDensity.compact,
|
||
onPressed: selectedNormalized.isBefore(futureLimitDate)
|
||
? _selectNextDay
|
||
: null,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
// ── Day strip ────────────────────────────────────────────
|
||
// reverse: true → index 0 (7 days from now) at the right edge;
|
||
// index _futureDays = today; past dates have higher indices.
|
||
SizedBox(
|
||
height: 56,
|
||
child: ListView.separated(
|
||
controller: _scrollController,
|
||
scrollDirection: Axis.horizontal,
|
||
reverse: true,
|
||
itemCount: _totalDays,
|
||
separatorBuilder: (_, __) => const SizedBox(width: _pillSpacing),
|
||
itemBuilder: (listContext, index) {
|
||
final date = todayNormalized
|
||
.add(Duration(days: _futureDays - index));
|
||
final isSelected = date == selectedNormalized;
|
||
final isDayToday = date == todayNormalized;
|
||
final isDayFuture = date.isAfter(todayNormalized);
|
||
|
||
return GestureDetector(
|
||
onTap: () => widget.onDateSelected(date),
|
||
child: AnimatedContainer(
|
||
duration: const Duration(milliseconds: 150),
|
||
width: _pillWidth,
|
||
decoration: BoxDecoration(
|
||
color: isSelected
|
||
? theme.colorScheme.primary
|
||
: isDayFuture
|
||
? theme.colorScheme.surfaceContainerLow
|
||
: theme.colorScheme.surfaceContainerHighest,
|
||
borderRadius: BorderRadius.circular(12),
|
||
border: isDayFuture && !isSelected
|
||
? Border.all(
|
||
color: theme.colorScheme.outline
|
||
.withValues(alpha: 0.3),
|
||
width: 1,
|
||
)
|
||
: null,
|
||
),
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Text(
|
||
DateFormat('EEE', localeCode).format(date),
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
color: isSelected
|
||
? theme.colorScheme.onPrimary
|
||
: isDayFuture
|
||
? theme.colorScheme.onSurfaceVariant
|
||
.withValues(alpha: 0.7)
|
||
: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
'${date.day}',
|
||
style: theme.textTheme.bodyMedium?.copyWith(
|
||
fontWeight: isSelected || isDayToday
|
||
? FontWeight.w700
|
||
: FontWeight.normal,
|
||
color: isSelected
|
||
? theme.colorScheme.onPrimary
|
||
: isDayToday
|
||
? theme.colorScheme.primary
|
||
: isDayFuture
|
||
? theme.colorScheme.onSurface
|
||
.withValues(alpha: 0.5)
|
||
: null,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Calories card ─────────────────────────────────────────────
|
||
|
||
class _CaloriesCard extends StatelessWidget {
|
||
final double loggedCalories;
|
||
final int dailyGoal;
|
||
final String? goalType;
|
||
|
||
const _CaloriesCard({
|
||
required this.loggedCalories,
|
||
required this.dailyGoal,
|
||
required this.goalType,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
if (dailyGoal == 0) return const SizedBox.shrink();
|
||
|
||
final l10n = AppLocalizations.of(context)!;
|
||
final theme = Theme.of(context);
|
||
final logged = loggedCalories.toInt();
|
||
final rawProgress = dailyGoal > 0 ? loggedCalories / dailyGoal : 0.0;
|
||
final isOverGoal = rawProgress > 1.0;
|
||
final ringColor = _ringColorFor(rawProgress, goalType);
|
||
|
||
final String secondaryValue;
|
||
final Color secondaryColor;
|
||
if (isOverGoal) {
|
||
final overBy = (loggedCalories - dailyGoal).toInt();
|
||
secondaryValue = '+$overBy ${l10n.caloriesUnit}';
|
||
secondaryColor = AppColors.error;
|
||
} else {
|
||
final remaining = (dailyGoal - loggedCalories).toInt();
|
||
secondaryValue = '$remaining ${l10n.caloriesUnit}';
|
||
secondaryColor = AppColors.textSecondary;
|
||
}
|
||
|
||
return Card(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(20),
|
||
child: Row(
|
||
children: [
|
||
SizedBox(
|
||
width: 160,
|
||
height: 160,
|
||
child: Stack(
|
||
alignment: Alignment.center,
|
||
children: [
|
||
CustomPaint(
|
||
size: const Size(160, 160),
|
||
painter: _CalorieRingPainter(
|
||
rawProgress: rawProgress,
|
||
ringColor: ringColor,
|
||
),
|
||
),
|
||
Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
'$logged',
|
||
style: theme.textTheme.headlineMedium?.copyWith(
|
||
fontWeight: FontWeight.w700,
|
||
color: ringColor,
|
||
),
|
||
),
|
||
Text(
|
||
l10n.caloriesUnit,
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: AppColors.textSecondary,
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
'${l10n.goalLabel} $dailyGoal',
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
color: AppColors.textSecondary,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: 20),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
_CalorieStat(
|
||
label: l10n.consumed,
|
||
value: '$logged ${l10n.caloriesUnit}',
|
||
valueColor: ringColor,
|
||
),
|
||
const SizedBox(height: 12),
|
||
_CalorieStat(
|
||
label: isOverGoal ? l10n.exceeded : l10n.remaining,
|
||
value: secondaryValue,
|
||
valueColor: secondaryColor,
|
||
),
|
||
const SizedBox(height: 12),
|
||
_CalorieStat(
|
||
label: l10n.goalLabel.replaceAll(':', '').trim(),
|
||
value: '$dailyGoal ${l10n.caloriesUnit}',
|
||
valueColor: AppColors.textPrimary,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _CalorieStat extends StatelessWidget {
|
||
final String label;
|
||
final String value;
|
||
final Color valueColor;
|
||
|
||
const _CalorieStat({
|
||
required this.label,
|
||
required this.value,
|
||
required this.valueColor,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
label,
|
||
style:
|
||
theme.textTheme.labelSmall?.copyWith(color: AppColors.textSecondary),
|
||
),
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
value,
|
||
style: theme.textTheme.bodyMedium?.copyWith(
|
||
color: valueColor,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Macros row ────────────────────────────────────────────────
|
||
|
||
class _MacrosRow extends StatelessWidget {
|
||
final double proteinG;
|
||
final double fatG;
|
||
final double carbsG;
|
||
|
||
const _MacrosRow({
|
||
required this.proteinG,
|
||
required this.fatG,
|
||
required this.carbsG,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final l10n = AppLocalizations.of(context)!;
|
||
return Row(
|
||
children: [
|
||
Expanded(
|
||
child: _MacroChip(
|
||
label: l10n.proteinLabel,
|
||
value: '${proteinG.toStringAsFixed(1)} ${l10n.gramsUnit}',
|
||
color: Colors.blue,
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: _MacroChip(
|
||
label: l10n.fatLabel,
|
||
value: '${fatG.toStringAsFixed(1)} ${l10n.gramsUnit}',
|
||
color: Colors.orange,
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: _MacroChip(
|
||
label: l10n.carbsLabel,
|
||
value: '${carbsG.toStringAsFixed(1)} ${l10n.gramsUnit}',
|
||
color: Colors.green,
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _MacroChip extends StatelessWidget {
|
||
final String label;
|
||
final String value;
|
||
final Color color;
|
||
|
||
const _MacroChip({
|
||
required this.label,
|
||
required this.value,
|
||
required this.color,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12),
|
||
decoration: BoxDecoration(
|
||
color: color.withValues(alpha: 0.1),
|
||
borderRadius: BorderRadius.circular(10),
|
||
),
|
||
child: Column(
|
||
children: [
|
||
Text(
|
||
value,
|
||
style: theme.textTheme.bodyMedium?.copyWith(
|
||
fontWeight: FontWeight.w700,
|
||
color: color,
|
||
),
|
||
),
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
label,
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Ring colour logic ──────────────────────────────────────────
|
||
|
||
Color _ringColorFor(double rawProgress, String? goalType) {
|
||
switch (goalType) {
|
||
case 'lose':
|
||
if (rawProgress >= 1.10) return AppColors.error;
|
||
if (rawProgress >= 0.95) return AppColors.warning;
|
||
if (rawProgress >= 0.75) return AppColors.success;
|
||
return AppColors.primary;
|
||
|
||
case 'maintain':
|
||
if (rawProgress >= 1.25) return AppColors.error;
|
||
if (rawProgress >= 1.11) return AppColors.warning;
|
||
if (rawProgress >= 0.90) return AppColors.success;
|
||
if (rawProgress >= 0.70) return AppColors.warning;
|
||
return AppColors.primary;
|
||
|
||
case 'gain':
|
||
if (rawProgress < 0.60) return AppColors.error;
|
||
if (rawProgress < 0.85) return AppColors.warning;
|
||
if (rawProgress <= 1.15) return AppColors.success;
|
||
return AppColors.primary;
|
||
|
||
default:
|
||
return AppColors.primary;
|
||
}
|
||
}
|
||
|
||
// ── Ring painter ───────────────────────────────────────────────
|
||
|
||
class _CalorieRingPainter extends CustomPainter {
|
||
final double rawProgress;
|
||
final Color ringColor;
|
||
|
||
const _CalorieRingPainter({
|
||
required this.rawProgress,
|
||
required this.ringColor,
|
||
});
|
||
|
||
@override
|
||
void paint(Canvas canvas, Size size) {
|
||
final center = Offset(size.width / 2, size.height / 2);
|
||
final radius = (size.width - 20) / 2;
|
||
const strokeWidth = 10.0;
|
||
const overflowStrokeWidth = 6.0;
|
||
const startAngle = -math.pi / 2;
|
||
|
||
final trackPaint = Paint()
|
||
..color = AppColors.separator.withValues(alpha: 0.5)
|
||
..style = PaintingStyle.stroke
|
||
..strokeWidth = strokeWidth
|
||
..strokeCap = StrokeCap.round;
|
||
canvas.drawCircle(center, radius, trackPaint);
|
||
|
||
final clampedProgress = rawProgress.clamp(0.0, 1.0);
|
||
if (clampedProgress > 0) {
|
||
final arcPaint = Paint()
|
||
..color = ringColor
|
||
..style = PaintingStyle.stroke
|
||
..strokeWidth = strokeWidth
|
||
..strokeCap = StrokeCap.round;
|
||
canvas.drawArc(
|
||
Rect.fromCircle(center: center, radius: radius),
|
||
startAngle,
|
||
clampedProgress * 2 * math.pi,
|
||
false,
|
||
arcPaint,
|
||
);
|
||
}
|
||
|
||
if (rawProgress > 1.0) {
|
||
final overflowProgress = rawProgress - 1.0;
|
||
final overflowPaint = Paint()
|
||
..color = ringColor.withValues(alpha: 0.70)
|
||
..style = PaintingStyle.stroke
|
||
..strokeWidth = overflowStrokeWidth
|
||
..strokeCap = StrokeCap.round;
|
||
canvas.drawArc(
|
||
Rect.fromCircle(center: center, radius: radius),
|
||
startAngle,
|
||
overflowProgress.clamp(0.0, 1.0) * 2 * math.pi,
|
||
false,
|
||
overflowPaint,
|
||
);
|
||
}
|
||
}
|
||
|
||
@override
|
||
bool shouldRepaint(_CalorieRingPainter oldDelegate) =>
|
||
oldDelegate.rawProgress != rawProgress ||
|
||
oldDelegate.ringColor != ringColor;
|
||
}
|
||
|
||
// ── Daily meals section ───────────────────────────────────────
|
||
|
||
class _DailyMealsSection extends ConsumerWidget {
|
||
final List<String> mealTypeIds;
|
||
final List<DiaryEntry> entries;
|
||
final String dateString;
|
||
|
||
const _DailyMealsSection({
|
||
required this.mealTypeIds,
|
||
required this.entries,
|
||
required this.dateString,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final l10n = AppLocalizations.of(context)!;
|
||
final theme = Theme.of(context);
|
||
final plannedSlots = ref.watch(plannedMealsProvider(dateString));
|
||
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(l10n.mealsSection, style: theme.textTheme.titleSmall),
|
||
const SizedBox(height: 8),
|
||
...mealTypeIds.map((mealTypeId) {
|
||
final mealTypeOption = mealTypeById(mealTypeId);
|
||
if (mealTypeOption == null) return const SizedBox.shrink();
|
||
final mealEntries = entries
|
||
.where((entry) => entry.mealType == mealTypeId)
|
||
.toList();
|
||
final mealPlannedSlots = plannedSlots
|
||
.where((slot) => slot.mealType == mealTypeId)
|
||
.toList();
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: 8),
|
||
child: _MealCard(
|
||
mealTypeOption: mealTypeOption,
|
||
entries: mealEntries,
|
||
dateString: dateString,
|
||
plannedSlots: mealPlannedSlots,
|
||
),
|
||
);
|
||
}),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
Future<void> _pickAndShowDishResult(
|
||
BuildContext context,
|
||
WidgetRef ref,
|
||
String mealTypeId,
|
||
) async {
|
||
final l10n = AppLocalizations.of(context)!;
|
||
|
||
// 1. Choose image source.
|
||
final source = await showModalBottomSheet<ImageSource>(
|
||
context: context,
|
||
builder: (_) => SafeArea(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
ListTile(
|
||
leading: const Icon(Icons.camera_alt),
|
||
title: Text(l10n.camera),
|
||
onTap: () => Navigator.pop(context, ImageSource.camera),
|
||
),
|
||
ListTile(
|
||
leading: const Icon(Icons.photo_library),
|
||
title: Text(l10n.gallery),
|
||
onTap: () => Navigator.pop(context, ImageSource.gallery),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
if (source == null || !context.mounted) return;
|
||
|
||
// 2. Pick image.
|
||
final image = await ImagePicker().pickImage(
|
||
source: source,
|
||
imageQuality: 70,
|
||
maxWidth: 1024,
|
||
maxHeight: 1024,
|
||
);
|
||
if (image == null || !context.mounted) return;
|
||
|
||
// 3. Show progress dialog.
|
||
// Capture root navigator before await to avoid GoRouter inner-navigator issues.
|
||
final rootNavigator = Navigator.of(context, rootNavigator: true);
|
||
final progressNotifier = _DishProgressNotifier(initialMessage: l10n.analyzingPhoto);
|
||
showDialog(
|
||
context: context,
|
||
barrierDismissible: false,
|
||
builder: (_) => _DishProgressDialog(notifier: progressNotifier),
|
||
);
|
||
|
||
// 4. Determine target date and meal type for context.
|
||
final selectedDate = ref.read(selectedDateProvider);
|
||
final targetDate = formatDateForDiary(selectedDate);
|
||
final localPreferences = ref.read(localPreferencesProvider);
|
||
final resolvedMealType = mealTypeId.isNotEmpty
|
||
? mealTypeId
|
||
: localPreferences.getLastUsedMealType();
|
||
|
||
// 5. Submit image and listen to SSE stream.
|
||
final service = ref.read(recognitionServiceProvider);
|
||
try {
|
||
final jobCreated = await service.submitDishRecognition(
|
||
image,
|
||
targetDate: targetDate,
|
||
targetMealType: resolvedMealType,
|
||
);
|
||
if (!context.mounted) return;
|
||
|
||
await for (final event in service.streamJobEvents(jobCreated.jobId)) {
|
||
if (!context.mounted) break;
|
||
|
||
switch (event) {
|
||
case DishJobQueued():
|
||
progressNotifier.update(
|
||
message: '${l10n.inQueue} · ${l10n.queuePosition(event.position + 1)}',
|
||
showUpgrade: event.position > 0,
|
||
);
|
||
case DishJobProcessing():
|
||
progressNotifier.update(message: l10n.processing);
|
||
case DishJobDone():
|
||
rootNavigator.pop(); // close dialog
|
||
if (!context.mounted) return;
|
||
showModalBottomSheet(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
useSafeArea: true,
|
||
builder: (sheetContext) => DishResultSheet(
|
||
dish: event.result,
|
||
preselectedMealType: mealTypeId.isNotEmpty ? mealTypeId : null,
|
||
jobId: jobCreated.jobId,
|
||
targetDate: targetDate,
|
||
onAdded: () => Navigator.pop(sheetContext),
|
||
),
|
||
);
|
||
return;
|
||
case DishJobFailed():
|
||
rootNavigator.pop(); // close dialog
|
||
if (!context.mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text(event.error),
|
||
action: SnackBarAction(
|
||
label: l10n.retry,
|
||
onPressed: () => _pickAndShowDishResult(context, ref, mealTypeId),
|
||
),
|
||
),
|
||
);
|
||
return;
|
||
}
|
||
}
|
||
} catch (recognitionError) {
|
||
debugPrint('Dish recognition error: $recognitionError');
|
||
if (context.mounted) {
|
||
rootNavigator.pop(); // close dialog
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text(l10n.recognitionFailed)),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Async recognition progress dialog
|
||
// ---------------------------------------------------------------------------
|
||
|
||
class _DishProgressState {
|
||
final String message;
|
||
final bool showUpgrade;
|
||
|
||
const _DishProgressState({
|
||
required this.message,
|
||
this.showUpgrade = false,
|
||
});
|
||
}
|
||
|
||
class _DishProgressNotifier extends ChangeNotifier {
|
||
late _DishProgressState _state;
|
||
|
||
_DishProgressNotifier({required String initialMessage})
|
||
: _state = _DishProgressState(message: initialMessage);
|
||
|
||
_DishProgressState get state => _state;
|
||
|
||
void update({required String message, bool showUpgrade = false}) {
|
||
_state = _DishProgressState(message: message, showUpgrade: showUpgrade);
|
||
notifyListeners();
|
||
}
|
||
}
|
||
|
||
class _DishProgressDialog extends StatelessWidget {
|
||
final _DishProgressNotifier notifier;
|
||
|
||
const _DishProgressDialog({required this.notifier});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final l10n = AppLocalizations.of(context)!;
|
||
return ListenableBuilder(
|
||
listenable: notifier,
|
||
builder: (context, _) {
|
||
final state = notifier.state;
|
||
return AlertDialog(
|
||
content: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const CircularProgressIndicator(),
|
||
const SizedBox(height: 16),
|
||
Text(state.message, textAlign: TextAlign.center),
|
||
if (state.showUpgrade) ...[
|
||
const SizedBox(height: 12),
|
||
Text(
|
||
l10n.upgradePrompt,
|
||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||
color: Theme.of(context).colorScheme.primary,
|
||
),
|
||
textAlign: TextAlign.center,
|
||
),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
class _MealCard extends ConsumerWidget {
|
||
final MealTypeOption mealTypeOption;
|
||
final List<DiaryEntry> entries;
|
||
final String dateString;
|
||
final List<MealSlot> plannedSlots;
|
||
|
||
const _MealCard({
|
||
required this.mealTypeOption,
|
||
required this.entries,
|
||
required this.dateString,
|
||
this.plannedSlots = const [],
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final l10n = AppLocalizations.of(context)!;
|
||
final theme = Theme.of(context);
|
||
final totalCalories = entries.fold<double>(
|
||
0.0, (sum, entry) => sum + (entry.calories ?? 0));
|
||
|
||
// Recipe IDs that are already confirmed in the diary — don't show ghost.
|
||
final confirmedRecipeIds =
|
||
entries.map((entry) => entry.recipeId).whereType<String>().toSet();
|
||
|
||
final unconfirmedSlots = plannedSlots
|
||
.where((slot) =>
|
||
slot.recipe != null &&
|
||
!confirmedRecipeIds.contains(slot.recipe!.id))
|
||
.toList();
|
||
|
||
return Card(
|
||
child: Column(
|
||
children: [
|
||
// Header row
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(16, 12, 8, 8),
|
||
child: Row(
|
||
children: [
|
||
Text(mealTypeOption.emoji,
|
||
style: const TextStyle(fontSize: 20)),
|
||
const SizedBox(width: 8),
|
||
Text(mealTypeLabel(mealTypeOption.id, l10n),
|
||
style: theme.textTheme.titleSmall),
|
||
const Spacer(),
|
||
if (totalCalories > 0)
|
||
Text(
|
||
'${totalCalories.toInt()} ${l10n.caloriesUnit}',
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
IconButton(
|
||
icon: const Icon(Icons.add, size: 20),
|
||
visualDensity: VisualDensity.compact,
|
||
tooltip: l10n.addDish,
|
||
onPressed: () {
|
||
showModalBottomSheet<void>(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
useSafeArea: true,
|
||
builder: (_) => FoodSearchSheet(
|
||
mealType: mealTypeOption.id,
|
||
date: dateString,
|
||
onAdded: () => ref
|
||
.invalidate(diaryProvider(dateString)),
|
||
onScanDish: () => _pickAndShowDishResult(
|
||
context, ref, mealTypeOption.id),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
],
|
||
),
|
||
),
|
||
// Planned (ghost) slots from the menu
|
||
if (unconfirmedSlots.isNotEmpty) ...[
|
||
const Divider(height: 1, indent: 16),
|
||
...unconfirmedSlots.map((slot) => _PlannedSlotTile(
|
||
slot: slot,
|
||
onConfirm: () =>
|
||
ref.read(diaryProvider(dateString).notifier).add({
|
||
'date': dateString,
|
||
'meal_type': mealTypeOption.id,
|
||
'recipe_id': slot.recipe!.id,
|
||
'name': slot.recipe!.title,
|
||
'portions': 1,
|
||
'source': 'menu_plan',
|
||
}),
|
||
)),
|
||
],
|
||
// Confirmed diary entries
|
||
if (entries.isNotEmpty) ...[
|
||
const Divider(height: 1, indent: 16),
|
||
...entries.map((entry) => _DiaryEntryTile(
|
||
entry: entry,
|
||
onDelete: () => ref
|
||
.read(diaryProvider(dateString).notifier)
|
||
.remove(entry.id),
|
||
)),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _DiaryEntryTile extends StatelessWidget {
|
||
final DiaryEntry entry;
|
||
final VoidCallback onDelete;
|
||
|
||
const _DiaryEntryTile({required this.entry, required this.onDelete});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final l10n = AppLocalizations.of(context)!;
|
||
final theme = Theme.of(context);
|
||
final calories = entry.calories?.toInt();
|
||
final hasProtein = (entry.proteinG ?? 0) > 0;
|
||
final hasFat = (entry.fatG ?? 0) > 0;
|
||
final hasCarbs = (entry.carbsG ?? 0) > 0;
|
||
|
||
return ListTile(
|
||
dense: true,
|
||
title: Text(entry.name, style: theme.textTheme.bodyMedium),
|
||
subtitle: (hasProtein || hasFat || hasCarbs)
|
||
? Text(
|
||
[
|
||
if (hasProtein) 'Б ${entry.proteinG!.toStringAsFixed(1)}',
|
||
if (hasFat) 'Ж ${entry.fatG!.toStringAsFixed(1)}',
|
||
if (hasCarbs) 'У ${entry.carbsG!.toStringAsFixed(1)}',
|
||
].join(' '),
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
)
|
||
: null,
|
||
trailing: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
if (calories != null)
|
||
Text(
|
||
'$calories ${l10n.caloriesUnit}',
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
IconButton(
|
||
icon: Icon(Icons.delete_outline,
|
||
size: 18, color: theme.colorScheme.error),
|
||
visualDensity: VisualDensity.compact,
|
||
onPressed: onDelete,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Future day header (wraps banner + menu-gen CTA) ────────────
|
||
|
||
class _FutureDayHeader extends ConsumerWidget {
|
||
final String dateString;
|
||
const _FutureDayHeader({required this.dateString});
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final l10n = AppLocalizations.of(context)!;
|
||
final date = DateTime.parse(dateString);
|
||
final weekString = isoWeekString(date);
|
||
final menuState = ref.watch(menuProvider(weekString));
|
||
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
_PlanningBanner(dateString: dateString),
|
||
const SizedBox(height: 8),
|
||
menuState.when(
|
||
loading: () => _GenerateLoadingCard(l10n: l10n),
|
||
error: (_, __) => _GenerateActionCard(
|
||
l10n: l10n,
|
||
onGenerate: () =>
|
||
ref.read(menuProvider(weekString).notifier).generate(),
|
||
),
|
||
data: (plan) => plan == null
|
||
? _GenerateActionCard(
|
||
l10n: l10n,
|
||
onGenerate: () =>
|
||
ref.read(menuProvider(weekString).notifier).generate(),
|
||
)
|
||
: _WeekPlannedChip(l10n: l10n),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _GenerateActionCard extends StatelessWidget {
|
||
final AppLocalizations l10n;
|
||
final VoidCallback onGenerate;
|
||
const _GenerateActionCard({required this.l10n, required this.onGenerate});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
return Container(
|
||
padding: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.surfaceContainerLow,
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Icon(Icons.auto_awesome,
|
||
color: theme.colorScheme.primary, size: 20),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: Text(
|
||
l10n.generateWeekLabel,
|
||
style: theme.textTheme.titleSmall?.copyWith(
|
||
color: theme.colorScheme.onSurface,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
l10n.generateWeekSubtitle,
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
FilledButton(
|
||
onPressed: onGenerate,
|
||
child: Text(l10n.generateWeekLabel),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _GenerateLoadingCard extends StatelessWidget {
|
||
final AppLocalizations l10n;
|
||
const _GenerateLoadingCard({required this.l10n});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.surfaceContainerLow,
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
SizedBox(
|
||
width: 18,
|
||
height: 18,
|
||
child: CircularProgressIndicator.adaptive(
|
||
strokeWidth: 2,
|
||
valueColor: AlwaysStoppedAnimation(theme.colorScheme.primary),
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Text(
|
||
l10n.generatingMenu,
|
||
style: theme.textTheme.bodyMedium?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _WeekPlannedChip extends StatelessWidget {
|
||
final AppLocalizations l10n;
|
||
const _WeekPlannedChip({required this.l10n});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.secondaryContainer,
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(Icons.check_circle_outline,
|
||
color: theme.colorScheme.onSecondaryContainer, size: 18),
|
||
const SizedBox(width: 8),
|
||
Text(
|
||
l10n.weekPlannedLabel,
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSecondaryContainer,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Planning banner (future dates) ────────────────────────────
|
||
|
||
class _PlanningBanner extends StatelessWidget {
|
||
final String dateString;
|
||
|
||
const _PlanningBanner({required this.dateString});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final l10n = AppLocalizations.of(context)!;
|
||
final theme = Theme.of(context);
|
||
final localeCode = Localizations.localeOf(context).toString();
|
||
|
||
String formattedDate;
|
||
try {
|
||
final date = DateTime.parse(dateString);
|
||
formattedDate = DateFormat('EEE, d MMMM', localeCode).format(date);
|
||
} catch (_) {
|
||
formattedDate = dateString;
|
||
}
|
||
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.5),
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Icon(Icons.calendar_today_outlined,
|
||
color: theme.colorScheme.onPrimaryContainer, size: 20),
|
||
const SizedBox(width: 10),
|
||
Text(
|
||
l10n.planningForDate(formattedDate),
|
||
style: theme.textTheme.bodyMedium?.copyWith(
|
||
color: theme.colorScheme.onPrimaryContainer,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Planned slot tile (ghost entry from menu) ──────────────────
|
||
|
||
class _PlannedSlotTile extends StatelessWidget {
|
||
final MealSlot slot;
|
||
final VoidCallback onConfirm;
|
||
|
||
const _PlannedSlotTile({required this.slot, required this.onConfirm});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final l10n = AppLocalizations.of(context)!;
|
||
final theme = Theme.of(context);
|
||
final recipe = slot.recipe;
|
||
if (recipe == null) return const SizedBox.shrink();
|
||
|
||
final calories = recipe.nutrition?.calories.toInt();
|
||
|
||
return Opacity(
|
||
opacity: 0.75,
|
||
child: ListTile(
|
||
dense: true,
|
||
title: Text(recipe.title, style: theme.textTheme.bodyMedium),
|
||
subtitle: Text(
|
||
l10n.plannedMealLabel,
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.primary.withValues(alpha: 0.8),
|
||
),
|
||
),
|
||
trailing: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
if (calories != null)
|
||
Text(
|
||
'$calories ${l10n.caloriesUnit}',
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
const SizedBox(width: 4),
|
||
IconButton(
|
||
icon: Icon(Icons.check_circle_outline,
|
||
size: 20, color: theme.colorScheme.primary),
|
||
visualDensity: VisualDensity.compact,
|
||
tooltip: l10n.markAsEaten,
|
||
onPressed: onConfirm,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── 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((expiringSoonItem) =>
|
||
'${expiringSoonItem.name} — ${expiringSoonItem.expiryLabel}')
|
||
.join(', '),
|
||
style:
|
||
theme.textTheme.bodySmall?.copyWith(color: onColor),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
Icon(Icons.chevron_right, color: onColor),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Today's recognition jobs widget ───────────────────────────
|
||
|
||
class _TodayJobsWidget extends ConsumerWidget {
|
||
final List<DishJobSummary> jobs;
|
||
|
||
const _TodayJobsWidget({required this.jobs});
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final l10n = AppLocalizations.of(context)!;
|
||
final theme = Theme.of(context);
|
||
final visibleJobs = jobs.take(3).toList();
|
||
final hasMore = jobs.length > 3;
|
||
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Text(l10n.dishRecognition, style: theme.textTheme.titleSmall),
|
||
const Spacer(),
|
||
if (hasMore)
|
||
TextButton(
|
||
onPressed: () => context.push('/scan/history'),
|
||
style: TextButton.styleFrom(
|
||
visualDensity: VisualDensity.compact,
|
||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||
),
|
||
child: Text(
|
||
l10n.all,
|
||
style: theme.textTheme.labelMedium?.copyWith(
|
||
color: theme.colorScheme.primary,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 8),
|
||
...visibleJobs.map((job) => Padding(
|
||
padding: const EdgeInsets.only(bottom: 8),
|
||
child: _JobTile(job: job),
|
||
)),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _JobTile extends ConsumerWidget {
|
||
final DishJobSummary job;
|
||
|
||
const _JobTile({required this.job});
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final l10n = AppLocalizations.of(context)!;
|
||
final theme = Theme.of(context);
|
||
|
||
final isDone = job.status == 'done';
|
||
final isFailed = job.status == 'failed';
|
||
final isProcessing =
|
||
job.status == 'processing' || job.status == 'pending';
|
||
|
||
final IconData statusIcon;
|
||
final Color statusColor;
|
||
if (isDone) {
|
||
statusIcon = Icons.check_circle_outline;
|
||
statusColor = Colors.green;
|
||
} else if (isFailed) {
|
||
statusIcon = Icons.error_outline;
|
||
statusColor = theme.colorScheme.error;
|
||
} else {
|
||
statusIcon = Icons.hourglass_top_outlined;
|
||
statusColor = theme.colorScheme.primary;
|
||
}
|
||
|
||
final dishName = job.result?.candidates.isNotEmpty == true
|
||
? job.result!.best.dishName
|
||
: null;
|
||
final subtitle = dishName ?? (isFailed ? (job.error ?? l10n.recognitionError) : l10n.recognizing);
|
||
|
||
return Card(
|
||
child: ListTile(
|
||
leading: Icon(statusIcon, color: statusColor),
|
||
title: Text(
|
||
dishName ?? (isProcessing ? l10n.recognizing : l10n.recognitionError),
|
||
style: theme.textTheme.bodyMedium,
|
||
),
|
||
subtitle: Text(
|
||
[
|
||
if (job.targetMealType != null) job.targetMealType,
|
||
if (job.targetDate != null) job.targetDate,
|
||
].join(' · ').isEmpty ? subtitle : [
|
||
if (job.targetMealType != null) job.targetMealType,
|
||
if (job.targetDate != null) job.targetDate,
|
||
].join(' · '),
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
onTap: isDone && job.result != null
|
||
? () {
|
||
showModalBottomSheet(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
useSafeArea: true,
|
||
builder: (sheetContext) => DishResultSheet(
|
||
dish: job.result!,
|
||
preselectedMealType: job.targetMealType,
|
||
jobId: job.id,
|
||
targetDate: job.targetDate,
|
||
createdAt: job.createdAt,
|
||
onAdded: () => Navigator.pop(sheetContext),
|
||
),
|
||
);
|
||
}
|
||
: null,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Quick actions ─────────────────────────────────────────────
|
||
|
||
class _QuickActionsRow extends StatelessWidget {
|
||
const _QuickActionsRow();
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final l10n = AppLocalizations.of(context)!;
|
||
return Row(
|
||
children: [
|
||
Expanded(
|
||
child: _ActionButton(
|
||
icon: Icons.document_scanner_outlined,
|
||
label: l10n.scanDish,
|
||
onTap: () => context.push('/scan'),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: _ActionButton(
|
||
icon: Icons.calendar_month_outlined,
|
||
label: l10n.menu,
|
||
onTap: () => context.push('/menu'),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: _ActionButton(
|
||
icon: Icons.history,
|
||
label: l10n.dishHistory,
|
||
onTap: () => context.push('/scan/history'),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
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 l10n = AppLocalizations.of(context)!;
|
||
final theme = Theme.of(context);
|
||
|
||
return SizedBox(
|
||
width: 140,
|
||
child: Card(
|
||
clipBehavior: Clip.antiAlias,
|
||
child: InkWell(
|
||
onTap: () {},
|
||
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()} ${l10n.caloriesUnit}',
|
||
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),
|
||
);
|
||
}
|