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:
dbastrikin
2026-03-18 16:32:06 +02:00
parent ad00998344
commit 39193ec13c
22 changed files with 1574 additions and 582 deletions

View File

@@ -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;