Files
food-ai/client/lib/features/scan/recognition_history_screen.dart
dbastrikin cf69a4a3d9 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>
2026-03-19 16:11:21 +02:00

130 lines
4.0 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../home/home_provider.dart';
import '../scan/dish_result_screen.dart';
import 'recognition_service.dart';
/// Full-screen page showing all of today's unlinked dish recognition jobs.
class RecognitionHistoryScreen extends ConsumerWidget {
const RecognitionHistoryScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final jobsState = ref.watch(todayJobsProvider);
return Scaffold(
appBar: AppBar(
title: const Text('История распознавания'),
),
body: jobsState.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (recognitionError, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Не удалось загрузить историю'),
const SizedBox(height: 12),
FilledButton(
onPressed: () => ref.invalidate(todayJobsProvider),
child: const Text('Повторить'),
),
],
),
),
data: (jobs) {
if (jobs.isEmpty) {
return const Center(
child: Text('Нет распознаваний за сегодня'),
);
}
return ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: jobs.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, index) =>
_HistoryJobTile(job: jobs[index]),
);
},
),
);
}
}
class _HistoryJobTile extends ConsumerWidget {
final DishJobSummary job;
const _HistoryJobTile({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 String titleText;
if (isDone) {
titleText = dishName ?? 'Блюдо распознано';
} else if (isProcessing) {
titleText = 'Распознаётся…';
} else {
titleText = 'Ошибка распознавания';
}
final contextParts = [
if (job.targetMealType != null) job.targetMealType!,
if (job.targetDate != null) job.targetDate!,
];
return Card(
child: ListTile(
leading: Icon(statusIcon, color: statusColor),
title: Text(titleText, style: theme.textTheme.bodyMedium),
subtitle: contextParts.isNotEmpty
? Text(
contextParts.join(' · '),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
)
: null,
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,
),
);
}
}