feat: async dish recognition (Kafka/Watermill/SSE) + remove Wire + consolidate migrations
Async recognition pipeline:
- POST /ai/recognize-dish → 202 {job_id, queue_position, estimated_seconds}
- GET /ai/jobs/{id}/stream — SSE stream: queued → processing → done/failed
- Kafka topics: ai.recognize.paid (3 partitions) + ai.recognize.free (1 partition)
- 5-worker WorkerPool with priority loop (paid consumers first)
- SSEBroker via PostgreSQL LISTEN/NOTIFY
- Kafka adapter migrated from franz-go to Watermill (watermill-kafka/v2)
- Docker Compose: added Kafka + Zookeeper + kafka-init service
- Flutter: recognition_service.dart uses SSE; home_screen shows live job status
Remove google/wire (archived):
- Deleted wire.go (wireinject spec) and wire_gen.go
- Added cmd/server/init.go — plain Go manual DI, same initApp() logic
- Removed github.com/google/wire from go.mod
Consolidate migrations:
- Merged 001_initial_schema + 002_seed_data + 003_recognition_jobs into single 001_initial_schema.sql
- Deleted 002_seed_data.sql and 003_recognition_jobs.sql
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -735,7 +735,7 @@ Future<void> _pickAndShowDishResult(
|
||||
WidgetRef ref,
|
||||
String mealTypeId,
|
||||
) async {
|
||||
// 1. Choose image source
|
||||
// 1. Choose image source.
|
||||
final source = await showModalBottomSheet<ImageSource>(
|
||||
context: context,
|
||||
builder: (_) => SafeArea(
|
||||
@@ -758,7 +758,7 @@ Future<void> _pickAndShowDishResult(
|
||||
);
|
||||
if (source == null || !context.mounted) return;
|
||||
|
||||
// 2. Pick image
|
||||
// 2. Pick image.
|
||||
final image = await ImagePicker().pickImage(
|
||||
source: source,
|
||||
imageQuality: 70,
|
||||
@@ -767,47 +767,66 @@ Future<void> _pickAndShowDishResult(
|
||||
);
|
||||
if (image == null || !context.mounted) return;
|
||||
|
||||
// 3. Show loading
|
||||
// Capture root navigator now (before await) to avoid using the wrong one later.
|
||||
// showDialog defaults to useRootNavigator: true; Navigator.pop(context) would resolve
|
||||
// to GoRouter's inner navigator instead, which only has /home and would crash.
|
||||
// 3. Show progress dialog.
|
||||
// Capture root navigator before await to avoid GoRouter inner-navigator issues.
|
||||
final rootNavigator = Navigator.of(context, rootNavigator: true);
|
||||
final progressNotifier = _DishProgressNotifier();
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => const AlertDialog(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Распознаём...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
builder: (_) => _DishProgressDialog(notifier: progressNotifier),
|
||||
);
|
||||
|
||||
// 4. Call API
|
||||
// 4. Submit image and listen to SSE stream.
|
||||
final service = ref.read(recognitionServiceProvider);
|
||||
try {
|
||||
final dish = await ref.read(recognitionServiceProvider).recognizeDish(image);
|
||||
final jobCreated = await service.submitDishRecognition(image);
|
||||
if (!context.mounted) return;
|
||||
rootNavigator.pop(); // close loading
|
||||
|
||||
// 5. Show result as bottom sheet
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
builder: (sheetContext) => DishResultSheet(
|
||||
dish: dish,
|
||||
preselectedMealType: mealTypeId,
|
||||
onAdded: () => Navigator.pop(sheetContext),
|
||||
),
|
||||
);
|
||||
await for (final event in service.streamJobEvents(jobCreated.jobId)) {
|
||||
if (!context.mounted) break;
|
||||
|
||||
switch (event) {
|
||||
case DishJobQueued():
|
||||
progressNotifier.update(
|
||||
message: 'Вы в очереди #${event.position + 1} · ~${event.estimatedSeconds} сек',
|
||||
showUpgrade: event.position > 0,
|
||||
);
|
||||
case DishJobProcessing():
|
||||
progressNotifier.update(message: 'Обрабатываем...');
|
||||
case DishJobDone():
|
||||
rootNavigator.pop(); // close dialog
|
||||
if (!context.mounted) return;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
builder: (sheetContext) => DishResultSheet(
|
||||
dish: event.result,
|
||||
preselectedMealType: mealTypeId,
|
||||
onAdded: () => Navigator.pop(sheetContext),
|
||||
),
|
||||
);
|
||||
return;
|
||||
case DishJobFailed():
|
||||
rootNavigator.pop(); // close dialog
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(event.error),
|
||||
action: SnackBarAction(
|
||||
label: 'Повторить',
|
||||
onPressed: () => _pickAndShowDishResult(context, ref, mealTypeId),
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (recognitionError) {
|
||||
debugPrint('Dish recognition error: $recognitionError');
|
||||
if (context.mounted) {
|
||||
rootNavigator.pop(); // close loading
|
||||
rootNavigator.pop(); // close dialog
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Не удалось распознать. Попробуйте ещё раз.'),
|
||||
@@ -817,6 +836,67 @@ Future<void> _pickAndShowDishResult(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Async recognition progress dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _DishProgressState {
|
||||
final String message;
|
||||
final bool showUpgrade;
|
||||
|
||||
const _DishProgressState({
|
||||
required this.message,
|
||||
this.showUpgrade = false,
|
||||
});
|
||||
}
|
||||
|
||||
class _DishProgressNotifier extends ChangeNotifier {
|
||||
_DishProgressState _state = const _DishProgressState(message: 'Анализируем фото...');
|
||||
|
||||
_DishProgressState get state => _state;
|
||||
|
||||
void update({required String message, bool showUpgrade = false}) {
|
||||
_state = _DishProgressState(message: message, showUpgrade: showUpgrade);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
class _DishProgressDialog extends StatelessWidget {
|
||||
final _DishProgressNotifier notifier;
|
||||
|
||||
const _DishProgressDialog({required this.notifier});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: notifier,
|
||||
builder: (context, _) {
|
||||
final state = notifier.state;
|
||||
return AlertDialog(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
Text(state.message, textAlign: TextAlign.center),
|
||||
if (state.showUpgrade) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Хотите без очереди? Upgrade →',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MealCard extends ConsumerWidget {
|
||||
final MealTypeOption mealTypeOption;
|
||||
final List<DiaryEntry> entries;
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import '../../core/api/api_client.dart';
|
||||
import '../../core/auth/auth_provider.dart';
|
||||
import '../../core/auth/secure_storage.dart';
|
||||
import '../../core/config/app_config.dart';
|
||||
import '../../core/locale/language_provider.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Models
|
||||
@@ -135,14 +140,68 @@ class DishResult {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Async job models
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The 202 response from POST /ai/recognize-dish.
|
||||
class DishJobCreated {
|
||||
final String jobId;
|
||||
final int queuePosition;
|
||||
final int estimatedSeconds;
|
||||
|
||||
const DishJobCreated({
|
||||
required this.jobId,
|
||||
required this.queuePosition,
|
||||
required this.estimatedSeconds,
|
||||
});
|
||||
|
||||
factory DishJobCreated.fromJson(Map<String, dynamic> json) {
|
||||
return DishJobCreated(
|
||||
jobId: json['job_id'] as String,
|
||||
queuePosition: json['queue_position'] as int? ?? 0,
|
||||
estimatedSeconds: json['estimated_seconds'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Events emitted by the SSE stream for a dish recognition job.
|
||||
sealed class DishJobEvent {}
|
||||
|
||||
class DishJobQueued extends DishJobEvent {
|
||||
final int position;
|
||||
final int estimatedSeconds;
|
||||
DishJobQueued({required this.position, required this.estimatedSeconds});
|
||||
}
|
||||
|
||||
class DishJobProcessing extends DishJobEvent {}
|
||||
|
||||
class DishJobDone extends DishJobEvent {
|
||||
final DishResult result;
|
||||
DishJobDone(this.result);
|
||||
}
|
||||
|
||||
class DishJobFailed extends DishJobEvent {
|
||||
final String error;
|
||||
DishJobFailed(this.error);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class RecognitionService {
|
||||
const RecognitionService(this._client);
|
||||
const RecognitionService(
|
||||
this._client,
|
||||
this._storage,
|
||||
this._appConfig,
|
||||
this._languageGetter,
|
||||
);
|
||||
|
||||
final ApiClient _client;
|
||||
final SecureStorageService _storage;
|
||||
final AppConfig _appConfig;
|
||||
final String Function() _languageGetter;
|
||||
|
||||
/// Recognizes food items from a receipt photo.
|
||||
Future<ReceiptResult> recognizeReceipt(XFile image) async {
|
||||
@@ -150,10 +209,10 @@ class RecognitionService {
|
||||
final data = await _client.post('/ai/recognize-receipt', data: payload);
|
||||
return ReceiptResult(
|
||||
items: (data['items'] as List<dynamic>? ?? [])
|
||||
.map((e) => RecognizedItem.fromJson(e as Map<String, dynamic>))
|
||||
.map((element) => RecognizedItem.fromJson(element as Map<String, dynamic>))
|
||||
.toList(),
|
||||
unrecognized: (data['unrecognized'] as List<dynamic>? ?? [])
|
||||
.map((e) => UnrecognizedItem.fromJson(e as Map<String, dynamic>))
|
||||
.map((element) => UnrecognizedItem.fromJson(element as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
@@ -166,15 +225,102 @@ class RecognitionService {
|
||||
data: {'images': imageList},
|
||||
);
|
||||
return (data['items'] as List<dynamic>? ?? [])
|
||||
.map((e) => RecognizedItem.fromJson(e as Map<String, dynamic>))
|
||||
.map((element) => RecognizedItem.fromJson(element as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Recognizes a dish and estimates its nutritional content.
|
||||
Future<DishResult> recognizeDish(XFile image) async {
|
||||
/// 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);
|
||||
final data = await _client.post('/ai/recognize-dish', data: payload);
|
||||
return DishResult.fromJson(data);
|
||||
return DishJobCreated.fromJson(data);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
Stream<DishJobEvent> streamJobEvents(String jobId) async* {
|
||||
final token = await _storage.getAccessToken();
|
||||
final language = _languageGetter();
|
||||
final url = '${_appConfig.apiBaseUrl}/ai/jobs/$jobId/stream';
|
||||
|
||||
final dio = Dio(BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 30),
|
||||
receiveTimeout: const Duration(minutes: 5),
|
||||
));
|
||||
|
||||
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 stream = response.data!.stream;
|
||||
final buffer = StringBuffer();
|
||||
String? currentEventName;
|
||||
|
||||
await for (final chunk in 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);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buffer
|
||||
..clear()
|
||||
..write(remaining);
|
||||
}
|
||||
}
|
||||
|
||||
DishJobEvent? _parseSseEvent(String? eventName, String dataPayload) {
|
||||
try {
|
||||
final json = jsonDecode(dataPayload) as Map<String, dynamic>;
|
||||
switch (eventName) {
|
||||
case 'queued':
|
||||
return DishJobQueued(
|
||||
position: json['position'] as int? ?? 0,
|
||||
estimatedSeconds: json['estimated_seconds'] as int? ?? 0,
|
||||
);
|
||||
case 'processing':
|
||||
return DishJobProcessing();
|
||||
case 'done':
|
||||
return DishJobDone(DishResult.fromJson(json));
|
||||
case 'failed':
|
||||
return DishJobFailed(json['error'] as String? ?? 'Recognition failed');
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, String>> _buildImagePayload(XFile image) async {
|
||||
@@ -188,5 +334,12 @@ class RecognitionService {
|
||||
}
|
||||
|
||||
final recognitionServiceProvider = Provider<RecognitionService>((ref) {
|
||||
return RecognitionService(ref.read(apiClientProvider));
|
||||
final config = ref.read(appConfigProvider);
|
||||
final storage = ref.read(secureStorageProvider);
|
||||
return RecognitionService(
|
||||
ref.read(apiClientProvider),
|
||||
storage,
|
||||
config,
|
||||
() => ref.read(languageProvider),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -125,10 +125,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
version: "1.4.0"
|
||||
checked_yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -668,26 +668,26 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.19"
|
||||
version: "0.12.17"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.13.0"
|
||||
version: "0.11.1"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.17.0"
|
||||
version: "1.16.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -993,10 +993,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.10"
|
||||
version: "0.7.6"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
Reference in New Issue
Block a user