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>
130 lines
4.0 KiB
Dart
130 lines
4.0 KiB
Dart
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,
|
||
),
|
||
);
|
||
}
|
||
}
|