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

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/storage/local_preferences_provider.dart';
import '../../features/menu/menu_provider.dart';
import '../../features/home/home_provider.dart';
import '../../shared/models/meal_type.dart';
@@ -15,11 +16,13 @@ class DishResultSheet extends ConsumerStatefulWidget {
required this.dish,
required this.onAdded,
this.preselectedMealType,
this.jobId,
});
final DishResult dish;
final VoidCallback onAdded;
final String? preselectedMealType;
final String? jobId;
@override
ConsumerState<DishResultSheet> createState() => _DishResultSheetState();
@@ -107,7 +110,10 @@ class _DishResultSheetState extends ConsumerState<DishResultSheet> {
'portion_g': _portionGrams,
'source': 'recognition',
if (_selected.dishId != null) 'dish_id': _selected.dishId,
if (widget.jobId != null) 'job_id': widget.jobId,
});
await ref.read(localPreferencesProvider).setLastUsedMealType(_mealType);
ref.invalidate(todayJobsProvider);
if (mounted) widget.onAdded();
} catch (addError) {
debugPrint('Add to diary error: $addError');

View File

@@ -0,0 +1,129 @@
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,
),
);
}
}

View File

@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:image_picker/image_picker.dart';
import '../../core/api/api_client.dart';
@@ -144,6 +145,41 @@ class DishResult {
// Async job models
// ---------------------------------------------------------------------------
/// A lightweight summary of a dish recognition job (no image payload).
class DishJobSummary {
final String id;
final String status;
final String? targetDate;
final String? targetMealType;
final DishResult? result;
final String? error;
final DateTime createdAt;
const DishJobSummary({
required this.id,
required this.status,
this.targetDate,
this.targetMealType,
this.result,
this.error,
required this.createdAt,
});
factory DishJobSummary.fromJson(Map<String, dynamic> json) {
return DishJobSummary(
id: json['id'] as String,
status: json['status'] as String? ?? '',
targetDate: json['target_date'] as String?,
targetMealType: json['target_meal_type'] as String?,
result: json['result'] != null
? DishResult.fromJson(json['result'] as Map<String, dynamic>)
: null,
error: json['error'] as String?,
createdAt: DateTime.parse(json['created_at'] as String),
);
}
}
/// The 202 response from POST /ai/recognize-dish.
class DishJobCreated {
final String jobId;
@@ -231,72 +267,87 @@ class RecognitionService {
/// Submits a dish image for async recognition.
/// Returns a [DishJobCreated] with the job ID and queue position.
Future<DishJobCreated> submitDishRecognition(XFile image) async {
final payload = await _buildImagePayload(image);
Future<DishJobCreated> submitDishRecognition(
XFile image, {
String? targetDate,
String? targetMealType,
}) async {
final imagePayload = await _buildImagePayload(image);
final payload = <String, dynamic>{...imagePayload};
if (targetDate != null) payload['target_date'] = targetDate;
if (targetMealType != null) payload['target_meal_type'] = targetMealType;
final data = await _client.post('/ai/recognize-dish', data: payload);
return DishJobCreated.fromJson(data);
}
/// Returns today's recognition jobs that have not yet been linked to a diary entry.
Future<List<DishJobSummary>> listTodayUnlinkedJobs() async {
final data = await _client.get('/ai/jobs') as List<dynamic>;
return data
.map((element) =>
DishJobSummary.fromJson(element as Map<String, dynamic>))
.toList();
}
/// Opens an SSE stream for job [jobId] and emits [DishJobEvent]s until the
/// job reaches a terminal state (done or failed) or the stream is cancelled.
///
/// Uses [http.Client] instead of Dio because on Flutter Web Dio relies on
/// XHR which does not support SSE streaming. [http.BrowserClient] reads the
/// response via XHR onProgress events and delivers chunks before the
/// connection is closed.
Stream<DishJobEvent> streamJobEvents(String jobId) async* {
final token = await _storage.getAccessToken();
final language = _languageGetter();
final url = '${_appConfig.apiBaseUrl}/ai/jobs/$jobId/stream';
final uri = Uri.parse('${_appConfig.apiBaseUrl}/ai/jobs/$jobId/stream');
final dio = Dio(BaseOptions(
connectTimeout: const Duration(seconds: 30),
receiveTimeout: const Duration(minutes: 5),
));
final httpClient = http.Client();
try {
final request = http.Request('GET', uri)
..headers['Authorization'] = token != null ? 'Bearer $token' : ''
..headers['Accept'] = 'text/event-stream'
..headers['Accept-Language'] = language
..headers['Cache-Control'] = 'no-cache';
final response = await dio.get<ResponseBody>(
url,
options: Options(
responseType: ResponseType.stream,
headers: {
'Authorization': token != null ? 'Bearer $token' : '',
'Accept': 'text/event-stream',
'Accept-Language': language,
'Cache-Control': 'no-cache',
},
),
);
final response = await httpClient.send(request).timeout(
const Duration(seconds: 30),
);
final stream = response.data!.stream;
final buffer = StringBuffer();
String? currentEventName;
final buffer = StringBuffer();
String? currentEventName;
await for (final chunk in stream.map(utf8.decode)) {
buffer.write(chunk);
final text = buffer.toString();
await for (final chunk in response.stream.map(utf8.decode)) {
buffer.write(chunk);
final text = buffer.toString();
// Process complete SSE messages (terminated by \n\n).
int doubleNewlineIndex;
var remaining = text;
while ((doubleNewlineIndex = remaining.indexOf('\n\n')) != -1) {
final message = remaining.substring(0, doubleNewlineIndex);
remaining = remaining.substring(doubleNewlineIndex + 2);
// Process complete SSE messages (terminated by \n\n).
int doubleNewlineIndex;
var remaining = text;
while ((doubleNewlineIndex = remaining.indexOf('\n\n')) != -1) {
final message = remaining.substring(0, doubleNewlineIndex);
remaining = remaining.substring(doubleNewlineIndex + 2);
for (final line in message.split('\n')) {
if (line.startsWith('event:')) {
currentEventName = line.substring(6).trim();
} else if (line.startsWith('data:')) {
final dataPayload = line.substring(5).trim();
final event = _parseSseEvent(currentEventName, dataPayload);
if (event != null) {
yield event;
if (event is DishJobDone || event is DishJobFailed) {
return;
for (final line in message.split('\n')) {
if (line.startsWith('event:')) {
currentEventName = line.substring(6).trim();
} else if (line.startsWith('data:')) {
final dataPayload = line.substring(5).trim();
final event = _parseSseEvent(currentEventName, dataPayload);
if (event != null) {
yield event;
if (event is DishJobDone || event is DishJobFailed) return;
}
currentEventName = null;
}
currentEventName = null;
}
}
}
buffer
..clear()
..write(remaining);
buffer
..clear()
..write(remaining);
}
} finally {
httpClient.close();
}
}