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:
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../features/scan/recognition_service.dart';
|
||||
import '../../shared/models/home_summary.dart';
|
||||
import 'home_service.dart';
|
||||
|
||||
@@ -33,3 +34,25 @@ final homeProvider =
|
||||
StateNotifierProvider<HomeNotifier, AsyncValue<HomeSummary>>(
|
||||
(ref) => HomeNotifier(ref.read(homeServiceProvider)),
|
||||
);
|
||||
|
||||
// ── Today's unlinked recognition jobs ─────────────────────────
|
||||
|
||||
class TodayJobsNotifier
|
||||
extends StateNotifier<AsyncValue<List<DishJobSummary>>> {
|
||||
final RecognitionService _service;
|
||||
|
||||
TodayJobsNotifier(this._service) : super(const AsyncValue.loading()) {
|
||||
load();
|
||||
}
|
||||
|
||||
Future<void> load() async {
|
||||
state = const AsyncValue.loading();
|
||||
state =
|
||||
await AsyncValue.guard(() => _service.listTodayUnlinkedJobs());
|
||||
}
|
||||
}
|
||||
|
||||
final todayJobsProvider =
|
||||
StateNotifierProvider<TodayJobsNotifier, AsyncValue<List<DishJobSummary>>>(
|
||||
(ref) => TodayJobsNotifier(ref.read(recognitionServiceProvider)),
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import '../../core/storage/local_preferences_provider.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../shared/models/diary_entry.dart';
|
||||
import '../../shared/models/home_summary.dart';
|
||||
@@ -47,11 +48,14 @@ class HomeScreen extends ConsumerWidget {
|
||||
final expiringSoon = homeSummaryState.valueOrNull?.expiringSoon ?? [];
|
||||
final recommendations = homeSummaryState.valueOrNull?.recommendations ?? [];
|
||||
|
||||
final todayJobs = ref.watch(todayJobsProvider).valueOrNull ?? [];
|
||||
|
||||
return Scaffold(
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
ref.read(homeProvider.notifier).load();
|
||||
ref.invalidate(diaryProvider(dateString));
|
||||
ref.invalidate(todayJobsProvider);
|
||||
},
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
@@ -78,6 +82,10 @@ class HomeScreen extends ConsumerWidget {
|
||||
fatG: loggedFat,
|
||||
carbsG: loggedCarbs,
|
||||
),
|
||||
if (todayJobs.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
_TodayJobsWidget(jobs: todayJobs),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
_DailyMealsSection(
|
||||
mealTypeIds: userMealTypes,
|
||||
@@ -777,10 +785,22 @@ Future<void> _pickAndShowDishResult(
|
||||
builder: (_) => _DishProgressDialog(notifier: progressNotifier),
|
||||
);
|
||||
|
||||
// 4. Submit image and listen to SSE stream.
|
||||
// 4. Determine target date and meal type for context.
|
||||
final selectedDate = ref.read(selectedDateProvider);
|
||||
final targetDate = formatDateForDiary(selectedDate);
|
||||
final localPreferences = ref.read(localPreferencesProvider);
|
||||
final resolvedMealType = mealTypeId.isNotEmpty
|
||||
? mealTypeId
|
||||
: localPreferences.getLastUsedMealType();
|
||||
|
||||
// 5. Submit image and listen to SSE stream.
|
||||
final service = ref.read(recognitionServiceProvider);
|
||||
try {
|
||||
final jobCreated = await service.submitDishRecognition(image);
|
||||
final jobCreated = await service.submitDishRecognition(
|
||||
image,
|
||||
targetDate: targetDate,
|
||||
targetMealType: resolvedMealType,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
|
||||
await for (final event in service.streamJobEvents(jobCreated.jobId)) {
|
||||
@@ -803,7 +823,8 @@ Future<void> _pickAndShowDishResult(
|
||||
useSafeArea: true,
|
||||
builder: (sheetContext) => DishResultSheet(
|
||||
dish: event.result,
|
||||
preselectedMealType: mealTypeId,
|
||||
preselectedMealType: mealTypeId.isNotEmpty ? mealTypeId : null,
|
||||
jobId: jobCreated.jobId,
|
||||
onAdded: () => Navigator.pop(sheetContext),
|
||||
),
|
||||
);
|
||||
@@ -1065,6 +1086,123 @@ class _ExpiringBanner extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Today's recognition jobs widget ───────────────────────────
|
||||
|
||||
class _TodayJobsWidget extends ConsumerWidget {
|
||||
final List<DishJobSummary> jobs;
|
||||
|
||||
const _TodayJobsWidget({required this.jobs});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final visibleJobs = jobs.take(3).toList();
|
||||
final hasMore = jobs.length > 3;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text('Распознавания', style: theme.textTheme.titleSmall),
|
||||
const Spacer(),
|
||||
if (hasMore)
|
||||
TextButton(
|
||||
onPressed: () => context.push('/scan/history'),
|
||||
style: TextButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
),
|
||||
child: Text(
|
||||
'Все',
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...visibleJobs.map((job) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: _JobTile(job: job),
|
||||
)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _JobTile extends ConsumerWidget {
|
||||
final DishJobSummary job;
|
||||
|
||||
const _JobTile({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 subtitle = dishName ?? (isFailed ? (job.error ?? 'Ошибка') : 'Обрабатывается…');
|
||||
|
||||
return Card(
|
||||
child: ListTile(
|
||||
leading: Icon(statusIcon, color: statusColor),
|
||||
title: Text(
|
||||
dishName ?? (isProcessing ? 'Распознаётся…' : 'Ошибка'),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
subtitle: Text(
|
||||
[
|
||||
if (job.targetMealType != null) job.targetMealType,
|
||||
if (job.targetDate != null) job.targetDate,
|
||||
].join(' · ').isEmpty ? subtitle : [
|
||||
if (job.targetMealType != null) job.targetMealType,
|
||||
if (job.targetDate != null) job.targetDate,
|
||||
].join(' · '),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Quick actions ─────────────────────────────────────────────
|
||||
|
||||
class _QuickActionsRow extends StatelessWidget {
|
||||
@@ -1089,6 +1227,14 @@ class _QuickActionsRow extends StatelessWidget {
|
||||
onTap: () => context.push('/menu'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _ActionButton(
|
||||
icon: Icons.history,
|
||||
label: 'История',
|
||||
onTap: () => context.push('/scan/history'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
129
client/lib/features/scan/recognition_history_screen.dart
Normal file
129
client/lib/features/scan/recognition_history_screen.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user