feat: dish recognition job context, diary linkage, home widget, history page

Backend:
- Rename recognition_jobs → dish_recognition_jobs; add target_date and
  target_meal_type columns to capture scan context at submission time
- Add job_id FK on meal_diary so entries are linked to their origin job
- New GET /ai/jobs endpoint returns today's unlinked jobs for the current user
- diary.Entry and CreateRequest gain job_id field; repository reads/writes it
- CORS middleware: allow Accept-Language and Cache-Control headers
- Logging middleware: implement http.Flusher on responseWriter (needed for SSE)
- Consolidate migrations into a single 001_initial_schema.sql

Flutter:
- POST /ai/recognize-dish now sends target_date and target_meal_type
- DishResultSheet accepts jobId; _addToDiary includes it in the diary payload,
  saves last-used meal type to SharedPreferences, invalidates todayJobsProvider
- TodayJobsNotifier + todayJobsProvider: loads unlinked jobs via GET /ai/jobs
- Home screen shows _TodayJobsWidget (up to 3 tiles) between macros and meals;
  tapping a done tile reopens DishResultSheet with the stored result
- Quick Actions row: third button "История" → /scan/history
- New RecognitionHistoryScreen: full-screen list of today's unlinked jobs
- LocalPreferences wrapper over SharedPreferences (last_used_meal_type)
- app_theme: apply Google Fonts Roboto as default font family

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dbastrikin
2026-03-19 16:11:21 +02:00
parent 1aaf20619d
commit cf69a4a3d9
21 changed files with 682 additions and 113 deletions

View File

@@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.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';
@@ -47,11 +48,14 @@ class HomeScreen extends ConsumerWidget {
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: [
@@ -78,6 +82,10 @@ class HomeScreen extends ConsumerWidget {
fatG: loggedFat,
carbsG: loggedCarbs,
),
if (todayJobs.isNotEmpty) ...[
const SizedBox(height: 16),
_TodayJobsWidget(jobs: todayJobs),
],
const SizedBox(height: 16),
_DailyMealsSection(
mealTypeIds: userMealTypes,
@@ -777,10 +785,22 @@ Future<void> _pickAndShowDishResult(
builder: (_) => _DishProgressDialog(notifier: progressNotifier),
);
// 4. Submit image and listen to SSE stream.
// 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);
final jobCreated = await service.submitDishRecognition(
image,
targetDate: targetDate,
targetMealType: resolvedMealType,
);
if (!context.mounted) return;
await for (final event in service.streamJobEvents(jobCreated.jobId)) {
@@ -803,7 +823,8 @@ Future<void> _pickAndShowDishResult(
useSafeArea: true,
builder: (sheetContext) => DishResultSheet(
dish: event.result,
preselectedMealType: mealTypeId,
preselectedMealType: mealTypeId.isNotEmpty ? mealTypeId : null,
jobId: jobCreated.jobId,
onAdded: () => Navigator.pop(sheetContext),
),
);
@@ -1065,6 +1086,123 @@ class _ExpiringBanner extends StatelessWidget {
}
}
// ── 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 theme = Theme.of(context);
final visibleJobs = jobs.take(3).toList();
final hasMore = jobs.length > 3;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text('Распознавания', 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(
'Все',
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 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 ?? 'Ошибка') : 'Обрабатывается…');
return Card(
child: ListTile(
leading: Icon(statusIcon, color: statusColor),
title: Text(
dishName ?? (isProcessing ? 'Распознаётся…' : 'Ошибка'),
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,
onAdded: () => Navigator.pop(sheetContext),
),
);
}
: null,
),
);
}
}
// ── Quick actions ─────────────────────────────────────────────
class _QuickActionsRow extends StatelessWidget {
@@ -1089,6 +1227,14 @@ class _QuickActionsRow extends StatelessWidget {
onTap: () => context.push('/menu'),
),
),
const SizedBox(width: 8),
Expanded(
child: _ActionButton(
icon: Icons.history,
label: 'История',
onTap: () => context.push('/scan/history'),
),
),
],
);
}