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;
|
||||
|
||||
Reference in New Issue
Block a user