From 4491fa5861a5852ba57c420391e79110cf769dd1 Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Tue, 2 Sep 2025 19:08:23 +0530 Subject: [PATCH] refactor: image uploads --- lib/core/services/share_receiver_service.dart | 22 +++- lib/features/chat/views/chat_page.dart | 68 ++++++----- lib/shared/services/tasks/outbound_task.dart | 14 +++ lib/shared/services/tasks/task_queue.dart | 21 ++++ lib/shared/services/tasks/task_worker.dart | 111 ++++++++++++++++++ 5 files changed, 199 insertions(+), 37 deletions(-) diff --git a/lib/core/services/share_receiver_service.dart b/lib/core/services/share_receiver_service.dart index 6cf86ee..e9467e3 100644 --- a/lib/core/services/share_receiver_service.dart +++ b/lib/core/services/share_receiver_service.dart @@ -158,12 +158,22 @@ Future _processPayload(Ref ref, SharedPayload payload) async { final activeConv = ref.read(activeConversationProvider); for (final file in files) { try { - await ref.read(taskQueueProvider.notifier).enqueueUploadMedia( - conversationId: activeConv?.id, - filePath: file.path, - fileName: path.basename(file.path), - fileSize: await file.length(), - ); + final ext = path.extension(file.path).toLowerCase(); + final isImage = ['.jpg', '.jpeg', '.png', '.gif', '.webp'].contains(ext); + if (isImage) { + await ref.read(taskQueueProvider.notifier).enqueueImageToDataUrl( + conversationId: activeConv?.id, + filePath: file.path, + fileName: path.basename(file.path), + ); + } else { + await ref.read(taskQueueProvider.notifier).enqueueUploadMedia( + conversationId: activeConv?.id, + filePath: file.path, + fileName: path.basename(file.path), + fileSize: await file.length(), + ); + } } catch (_) {} } } diff --git a/lib/features/chat/views/chat_page.dart b/lib/features/chat/views/chat_page.dart index 54a01a3..efa194d 100644 --- a/lib/features/chat/views/chat_page.dart +++ b/lib/features/chat/views/chat_page.dart @@ -20,6 +20,8 @@ import '../widgets/file_attachment_widget.dart'; // import '../widgets/voice_input_sheet.dart'; // deprecated: replaced by inline voice input import '../services/voice_input_service.dart'; import '../services/file_attachment_service.dart'; +import 'package:path/path.dart' as path; +import '../../../shared/services/tasks/task_queue.dart'; import '../../tools/providers/tools_providers.dart'; import '../../navigation/widgets/chats_drawer.dart'; import '../../../shared/widgets/offline_indicator.dart'; @@ -33,7 +35,6 @@ import '../../onboarding/views/onboarding_sheet.dart'; import '../../../shared/widgets/sheet_handle.dart'; import '../../../shared/widgets/conduit_components.dart'; import '../../../core/services/settings_service.dart'; -import '../../../shared/services/tasks/task_queue.dart'; // Removed unused PlatformUtils import import '../../../core/services/platform_service.dart' as ps; import 'package:flutter/gestures.dart' show DragStartBehavior; @@ -358,20 +359,30 @@ class _ChatPageState extends ConsumerState { // Add files to the attachment list ref.read(attachedFilesProvider.notifier).addFiles(files); - // Start uploading files + // Enqueue uploads via task queue for unified retry/progress + final activeConv = ref.read(activeConversationProvider); for (final file in files) { - final uploadStream = fileService.uploadFile(file); - uploadStream.listen( - (state) { - ref - .read(attachedFilesProvider.notifier) - .updateFileState(file.path, state); - }, - onError: (error) { - if (!mounted) return; - debugPrint('Upload failed: $error'); - }, - ); + try { + final ext = path.extension(file.path).toLowerCase(); + final isImage = ['.jpg', '.jpeg', '.png', '.gif', '.webp'].contains(ext); + if (isImage) { + await ref.read(taskQueueProvider.notifier).enqueueImageToDataUrl( + conversationId: activeConv?.id, + filePath: file.path, + fileName: path.basename(file.path), + ); + } else { + await ref.read(taskQueueProvider.notifier).enqueueUploadMedia( + conversationId: activeConv?.id, + filePath: file.path, + fileName: path.basename(file.path), + fileSize: await file.length(), + ); + } + } catch (e) { + if (!mounted) return; + debugPrint('Enqueue upload failed: $e'); + } } } catch (e) { if (!mounted) return; @@ -428,23 +439,18 @@ class _ChatPageState extends ConsumerState { ref.read(attachedFilesProvider.notifier).addFiles([image]); debugPrint('DEBUG: Image added to attachment list'); - // Start uploading image - debugPrint('DEBUG: Starting image upload...'); - final uploadStream = fileService.uploadFile(image); - uploadStream.listen( - (state) { - debugPrint( - 'DEBUG: Upload state update - Status: ${state.status}, Progress: ${state.progress}, FileId: ${state.fileId}', - ); - ref - .read(attachedFilesProvider.notifier) - .updateFileState(image.path, state); - }, - onError: (error) { - debugPrint('DEBUG: Image upload error: $error'); - if (!mounted) return; - }, - ); + // Enqueue upload via task queue for unified retry/progress + debugPrint('DEBUG: Enqueueing image upload...'); + final activeConv = ref.read(activeConversationProvider); + try { + await ref.read(taskQueueProvider.notifier).enqueueImageToDataUrl( + conversationId: activeConv?.id, + filePath: image.path, + fileName: path.basename(image.path), + ); + } catch (e) { + debugPrint('DEBUG: Enqueue image upload failed: $e'); + } } catch (e) { debugPrint('DEBUG: Image attachment error: $e'); if (!mounted) return; diff --git a/lib/shared/services/tasks/outbound_task.dart b/lib/shared/services/tasks/outbound_task.dart index 7896b9c..17a4b86 100644 --- a/lib/shared/services/tasks/outbound_task.dart +++ b/lib/shared/services/tasks/outbound_task.dart @@ -98,6 +98,20 @@ abstract class OutboundTask with _$OutboundTask { String? error, }) = GenerateTitleTask; + const factory OutboundTask.imageToDataUrl({ + required String id, + String? conversationId, + required String filePath, + required String fileName, + @Default(TaskStatus.queued) TaskStatus status, + @Default(0) int attempt, + String? idempotencyKey, + DateTime? enqueuedAt, + DateTime? startedAt, + DateTime? completedAt, + String? error, + }) = ImageToDataUrlTask; + factory OutboundTask.fromJson(Map json) => _$OutboundTaskFromJson(json); diff --git a/lib/shared/services/tasks/task_queue.dart b/lib/shared/services/tasks/task_queue.dart index 0dc48c0..3d25845 100644 --- a/lib/shared/services/tasks/task_queue.dart +++ b/lib/shared/services/tasks/task_queue.dart @@ -285,4 +285,25 @@ class TaskQueueNotifier extends StateNotifier> { _process(); return id; } + + Future enqueueImageToDataUrl({ + required String? conversationId, + required String filePath, + required String fileName, + String? idempotencyKey, + }) async { + final id = _uuid.v4(); + final task = OutboundTask.imageToDataUrl( + id: id, + conversationId: conversationId, + filePath: filePath, + fileName: fileName, + idempotencyKey: idempotencyKey, + enqueuedAt: DateTime.now(), + ); + state = [...state, task]; + await _save(); + _process(); + return id; + } } diff --git a/lib/shared/services/tasks/task_worker.dart b/lib/shared/services/tasks/task_worker.dart index f97c190..28dba6c 100644 --- a/lib/shared/services/tasks/task_worker.dart +++ b/lib/shared/services/tasks/task_worker.dart @@ -1,5 +1,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; +import 'dart:io'; +import 'dart:convert'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/providers/app_providers.dart'; @@ -7,6 +9,8 @@ import '../../../core/services/attachment_upload_queue.dart'; import '../../../core/utils/debug_logger.dart'; import '../../../core/models/chat_message.dart'; import '../../../features/chat/providers/chat_providers.dart' as chat; +import '../../../features/chat/services/file_attachment_service.dart'; +import 'package:path/path.dart' as path; import 'package:uuid/uuid.dart'; import 'outbound_task.dart'; @@ -20,6 +24,7 @@ class TaskWorker { uploadMedia: _performUploadMedia, executeToolCall: _performExecuteToolCall, generateImage: _performGenerateImage, + imageToDataUrl: _performImageToDataUrl, saveConversation: _performSaveConversation, generateTitle: _performGenerateTitle, ); @@ -96,6 +101,34 @@ class TaskWorker { entry = null; } if (entry == null) return; + + // Reflect progress into UI attachment state if that file is present + try { + final current = _ref.read(attachedFilesProvider); + final idx = current.indexWhere((f) => f.file.path == task.filePath); + if (idx != -1) { + final existing = current[idx]; + final status = switch (entry.status) { + QueuedAttachmentStatus.pending => FileUploadStatus.uploading, + QueuedAttachmentStatus.uploading => FileUploadStatus.uploading, + QueuedAttachmentStatus.completed => FileUploadStatus.completed, + QueuedAttachmentStatus.failed => FileUploadStatus.failed, + QueuedAttachmentStatus.cancelled => FileUploadStatus.failed, + }; + final newState = FileUploadState( + file: File(task.filePath), + fileName: task.fileName, + fileSize: task.fileSize ?? existing.fileSize, + progress: status == FileUploadStatus.completed ? 1.0 : existing.progress, + status: status, + fileId: entry.fileId ?? existing.fileId, + error: entry.lastError, + ); + _ref + .read(attachedFilesProvider.notifier) + .updateFileState(task.filePath, newState); + } + } catch (_) {} switch (entry.status) { case QueuedAttachmentStatus.completed: case QueuedAttachmentStatus.failed: @@ -281,6 +314,84 @@ class TaskWorker { } } + Future _performImageToDataUrl(ImageToDataUrlTask task) async { + try { + // Update UI to uploading state first + try { + final current = _ref.read(attachedFilesProvider); + final idx = current.indexWhere((f) => f.file.path == task.filePath); + if (idx != -1) { + final existing = current[idx]; + final uploading = FileUploadState( + file: existing.file, + fileName: task.fileName, + fileSize: existing.fileSize, + progress: 0.5, + status: FileUploadStatus.uploading, + fileId: existing.fileId, + ); + _ref.read(attachedFilesProvider.notifier).updateFileState( + task.filePath, + uploading, + ); + } + } catch (_) {} + + // Read file and convert to data URL + final file = File(task.filePath); + final bytes = await file.readAsBytes(); + final b64 = base64Encode(bytes); + final ext = path.extension(task.fileName).toLowerCase(); + String mime = 'image/png'; + if (ext == '.jpg' || ext == '.jpeg') mime = 'image/jpeg'; + else if (ext == '.gif') mime = 'image/gif'; + else if (ext == '.webp') mime = 'image/webp'; + final dataUrl = 'data:$mime;base64,$b64'; + + // Mark as completed with data URL as fileId + try { + final current = _ref.read(attachedFilesProvider); + final idx = current.indexWhere((f) => f.file.path == task.filePath); + if (idx != -1) { + final existing = current[idx]; + final done = FileUploadState( + file: existing.file, + fileName: task.fileName, + fileSize: existing.fileSize, + progress: 1.0, + status: FileUploadStatus.completed, + fileId: dataUrl, + ); + _ref.read(attachedFilesProvider.notifier).updateFileState( + task.filePath, + done, + ); + } + } catch (_) {} + } catch (e) { + try { + final current = _ref.read(attachedFilesProvider); + final idx = current.indexWhere((f) => f.file.path == task.filePath); + if (idx != -1) { + final existing = current[idx]; + final failed = FileUploadState( + file: existing.file, + fileName: task.fileName, + fileSize: existing.fileSize, + progress: 0.0, + status: FileUploadStatus.failed, + fileId: existing.fileId, + error: e.toString(), + ); + _ref.read(attachedFilesProvider.notifier).updateFileState( + task.filePath, + failed, + ); + } + } catch (_) {} + } + } + Future _performSaveConversation(SaveConversationTask task) async { final api = _ref.read(apiServiceProvider); final messages = _ref.read(chat.chatMessagesProvider);