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:
@@ -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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user