From 8a291c80b7432c11ba99639a5da913ccd2ce5dc7 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Mon, 10 Nov 2025 01:11:43 +0800 Subject: [PATCH] :sparkles: Upload tasks overlay --- lib/models/upload_task.dart | 1 + lib/models/upload_task.freezed.dart | 45 +-- lib/models/upload_task.g.dart | 2 + lib/pods/chat/messages_notifier.dart | 4 +- lib/pods/upload_tasks.dart | 259 +++++++++++--- lib/screens/account/me/profile_update.dart | 2 +- lib/screens/chat/chat_form.dart | 2 +- lib/screens/chat/room.dart | 3 +- lib/screens/creators/publishers_form.dart | 2 +- lib/screens/developers/edit_app.dart | 2 +- lib/screens/developers/edit_bot.dart | 2 +- lib/screens/realm/realm_form.dart | 2 +- lib/services/file_uploader.dart | 67 +--- lib/widgets/content/attachment_preview.dart | 2 +- lib/widgets/content/cloud_file_picker.dart | 3 +- lib/widgets/post/compose_shared.dart | 4 +- lib/widgets/share/share_sheet.dart | 2 +- lib/widgets/upload_overlay.dart | 374 ++++++++++++++++---- 18 files changed, 582 insertions(+), 196 deletions(-) diff --git a/lib/models/upload_task.dart b/lib/models/upload_task.dart index 1a5fe9ea..1dac519b 100644 --- a/lib/models/upload_task.dart +++ b/lib/models/upload_task.dart @@ -30,6 +30,7 @@ sealed class UploadTask with _$UploadTask { required UploadTaskStatus status, required DateTime createdAt, required DateTime updatedAt, + double? transmissionProgress, // Local file upload progress (0.0-1.0) String? errorMessage, SnCloudFile? result, String? poolId, diff --git a/lib/models/upload_task.freezed.dart b/lib/models/upload_task.freezed.dart index d16139ae..9e12fce7 100644 --- a/lib/models/upload_task.freezed.dart +++ b/lib/models/upload_task.freezed.dart @@ -15,7 +15,8 @@ T _$identity(T value) => value; /// @nodoc mixin _$UploadTask { - String get id; String get taskId; String get fileName; String get contentType; int get fileSize; int get uploadedBytes; int get totalChunks; int get uploadedChunks; UploadTaskStatus get status; DateTime get createdAt; DateTime get updatedAt; String? get errorMessage; SnCloudFile? get result; String? get poolId; String? get bundleId; String? get encryptPassword; String? get expiredAt; + String get id; String get taskId; String get fileName; String get contentType; int get fileSize; int get uploadedBytes; int get totalChunks; int get uploadedChunks; UploadTaskStatus get status; DateTime get createdAt; DateTime get updatedAt; double? get transmissionProgress;// Local file upload progress (0.0-1.0) + String? get errorMessage; SnCloudFile? get result; String? get poolId; String? get bundleId; String? get encryptPassword; String? get expiredAt; /// Create a copy of UploadTask /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -28,16 +29,16 @@ $UploadTaskCopyWith get copyWith => _$UploadTaskCopyWithImpl Object.hash(runtimeType,id,taskId,fileName,contentType,fileSize,uploadedBytes,totalChunks,uploadedChunks,status,createdAt,updatedAt,errorMessage,result,poolId,bundleId,encryptPassword,expiredAt); +int get hashCode => Object.hash(runtimeType,id,taskId,fileName,contentType,fileSize,uploadedBytes,totalChunks,uploadedChunks,status,createdAt,updatedAt,transmissionProgress,errorMessage,result,poolId,bundleId,encryptPassword,expiredAt); @override String toString() { - return 'UploadTask(id: $id, taskId: $taskId, fileName: $fileName, contentType: $contentType, fileSize: $fileSize, uploadedBytes: $uploadedBytes, totalChunks: $totalChunks, uploadedChunks: $uploadedChunks, status: $status, createdAt: $createdAt, updatedAt: $updatedAt, errorMessage: $errorMessage, result: $result, poolId: $poolId, bundleId: $bundleId, encryptPassword: $encryptPassword, expiredAt: $expiredAt)'; + return 'UploadTask(id: $id, taskId: $taskId, fileName: $fileName, contentType: $contentType, fileSize: $fileSize, uploadedBytes: $uploadedBytes, totalChunks: $totalChunks, uploadedChunks: $uploadedChunks, status: $status, createdAt: $createdAt, updatedAt: $updatedAt, transmissionProgress: $transmissionProgress, errorMessage: $errorMessage, result: $result, poolId: $poolId, bundleId: $bundleId, encryptPassword: $encryptPassword, expiredAt: $expiredAt)'; } @@ -48,7 +49,7 @@ abstract mixin class $UploadTaskCopyWith<$Res> { factory $UploadTaskCopyWith(UploadTask value, $Res Function(UploadTask) _then) = _$UploadTaskCopyWithImpl; @useResult $Res call({ - String id, String taskId, String fileName, String contentType, int fileSize, int uploadedBytes, int totalChunks, int uploadedChunks, UploadTaskStatus status, DateTime createdAt, DateTime updatedAt, String? errorMessage, SnCloudFile? result, String? poolId, String? bundleId, String? encryptPassword, String? expiredAt + String id, String taskId, String fileName, String contentType, int fileSize, int uploadedBytes, int totalChunks, int uploadedChunks, UploadTaskStatus status, DateTime createdAt, DateTime updatedAt, double? transmissionProgress, String? errorMessage, SnCloudFile? result, String? poolId, String? bundleId, String? encryptPassword, String? expiredAt }); @@ -65,7 +66,7 @@ class _$UploadTaskCopyWithImpl<$Res> /// Create a copy of UploadTask /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? taskId = null,Object? fileName = null,Object? contentType = null,Object? fileSize = null,Object? uploadedBytes = null,Object? totalChunks = null,Object? uploadedChunks = null,Object? status = null,Object? createdAt = null,Object? updatedAt = null,Object? errorMessage = freezed,Object? result = freezed,Object? poolId = freezed,Object? bundleId = freezed,Object? encryptPassword = freezed,Object? expiredAt = freezed,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? taskId = null,Object? fileName = null,Object? contentType = null,Object? fileSize = null,Object? uploadedBytes = null,Object? totalChunks = null,Object? uploadedChunks = null,Object? status = null,Object? createdAt = null,Object? updatedAt = null,Object? transmissionProgress = freezed,Object? errorMessage = freezed,Object? result = freezed,Object? poolId = freezed,Object? bundleId = freezed,Object? encryptPassword = freezed,Object? expiredAt = freezed,}) { return _then(_self.copyWith( id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable as String,taskId: null == taskId ? _self.taskId : taskId // ignore: cast_nullable_to_non_nullable @@ -78,7 +79,8 @@ as int,uploadedChunks: null == uploadedChunks ? _self.uploadedChunks : uploadedC as int,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable as UploadTaskStatus,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable -as DateTime,errorMessage: freezed == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable +as DateTime,transmissionProgress: freezed == transmissionProgress ? _self.transmissionProgress : transmissionProgress // ignore: cast_nullable_to_non_nullable +as double?,errorMessage: freezed == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable as String?,result: freezed == result ? _self.result : result // ignore: cast_nullable_to_non_nullable as SnCloudFile?,poolId: freezed == poolId ? _self.poolId : poolId // ignore: cast_nullable_to_non_nullable as String?,bundleId: freezed == bundleId ? _self.bundleId : bundleId // ignore: cast_nullable_to_non_nullable @@ -178,10 +180,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( String id, String taskId, String fileName, String contentType, int fileSize, int uploadedBytes, int totalChunks, int uploadedChunks, UploadTaskStatus status, DateTime createdAt, DateTime updatedAt, String? errorMessage, SnCloudFile? result, String? poolId, String? bundleId, String? encryptPassword, String? expiredAt)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( String id, String taskId, String fileName, String contentType, int fileSize, int uploadedBytes, int totalChunks, int uploadedChunks, UploadTaskStatus status, DateTime createdAt, DateTime updatedAt, double? transmissionProgress, String? errorMessage, SnCloudFile? result, String? poolId, String? bundleId, String? encryptPassword, String? expiredAt)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _UploadTask() when $default != null: -return $default(_that.id,_that.taskId,_that.fileName,_that.contentType,_that.fileSize,_that.uploadedBytes,_that.totalChunks,_that.uploadedChunks,_that.status,_that.createdAt,_that.updatedAt,_that.errorMessage,_that.result,_that.poolId,_that.bundleId,_that.encryptPassword,_that.expiredAt);case _: +return $default(_that.id,_that.taskId,_that.fileName,_that.contentType,_that.fileSize,_that.uploadedBytes,_that.totalChunks,_that.uploadedChunks,_that.status,_that.createdAt,_that.updatedAt,_that.transmissionProgress,_that.errorMessage,_that.result,_that.poolId,_that.bundleId,_that.encryptPassword,_that.expiredAt);case _: return orElse(); } @@ -199,10 +201,10 @@ return $default(_that.id,_that.taskId,_that.fileName,_that.contentType,_that.fil /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( String id, String taskId, String fileName, String contentType, int fileSize, int uploadedBytes, int totalChunks, int uploadedChunks, UploadTaskStatus status, DateTime createdAt, DateTime updatedAt, String? errorMessage, SnCloudFile? result, String? poolId, String? bundleId, String? encryptPassword, String? expiredAt) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( String id, String taskId, String fileName, String contentType, int fileSize, int uploadedBytes, int totalChunks, int uploadedChunks, UploadTaskStatus status, DateTime createdAt, DateTime updatedAt, double? transmissionProgress, String? errorMessage, SnCloudFile? result, String? poolId, String? bundleId, String? encryptPassword, String? expiredAt) $default,) {final _that = this; switch (_that) { case _UploadTask(): -return $default(_that.id,_that.taskId,_that.fileName,_that.contentType,_that.fileSize,_that.uploadedBytes,_that.totalChunks,_that.uploadedChunks,_that.status,_that.createdAt,_that.updatedAt,_that.errorMessage,_that.result,_that.poolId,_that.bundleId,_that.encryptPassword,_that.expiredAt);} +return $default(_that.id,_that.taskId,_that.fileName,_that.contentType,_that.fileSize,_that.uploadedBytes,_that.totalChunks,_that.uploadedChunks,_that.status,_that.createdAt,_that.updatedAt,_that.transmissionProgress,_that.errorMessage,_that.result,_that.poolId,_that.bundleId,_that.encryptPassword,_that.expiredAt);} } /// A variant of `when` that fallback to returning `null` /// @@ -216,10 +218,10 @@ return $default(_that.id,_that.taskId,_that.fileName,_that.contentType,_that.fil /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, String taskId, String fileName, String contentType, int fileSize, int uploadedBytes, int totalChunks, int uploadedChunks, UploadTaskStatus status, DateTime createdAt, DateTime updatedAt, String? errorMessage, SnCloudFile? result, String? poolId, String? bundleId, String? encryptPassword, String? expiredAt)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, String taskId, String fileName, String contentType, int fileSize, int uploadedBytes, int totalChunks, int uploadedChunks, UploadTaskStatus status, DateTime createdAt, DateTime updatedAt, double? transmissionProgress, String? errorMessage, SnCloudFile? result, String? poolId, String? bundleId, String? encryptPassword, String? expiredAt)? $default,) {final _that = this; switch (_that) { case _UploadTask() when $default != null: -return $default(_that.id,_that.taskId,_that.fileName,_that.contentType,_that.fileSize,_that.uploadedBytes,_that.totalChunks,_that.uploadedChunks,_that.status,_that.createdAt,_that.updatedAt,_that.errorMessage,_that.result,_that.poolId,_that.bundleId,_that.encryptPassword,_that.expiredAt);case _: +return $default(_that.id,_that.taskId,_that.fileName,_that.contentType,_that.fileSize,_that.uploadedBytes,_that.totalChunks,_that.uploadedChunks,_that.status,_that.createdAt,_that.updatedAt,_that.transmissionProgress,_that.errorMessage,_that.result,_that.poolId,_that.bundleId,_that.encryptPassword,_that.expiredAt);case _: return null; } @@ -231,7 +233,7 @@ return $default(_that.id,_that.taskId,_that.fileName,_that.contentType,_that.fil @JsonSerializable() class _UploadTask extends UploadTask { - const _UploadTask({required this.id, required this.taskId, required this.fileName, required this.contentType, required this.fileSize, required this.uploadedBytes, required this.totalChunks, required this.uploadedChunks, required this.status, required this.createdAt, required this.updatedAt, this.errorMessage, this.result, this.poolId, this.bundleId, this.encryptPassword, this.expiredAt}): super._(); + const _UploadTask({required this.id, required this.taskId, required this.fileName, required this.contentType, required this.fileSize, required this.uploadedBytes, required this.totalChunks, required this.uploadedChunks, required this.status, required this.createdAt, required this.updatedAt, this.transmissionProgress, this.errorMessage, this.result, this.poolId, this.bundleId, this.encryptPassword, this.expiredAt}): super._(); factory _UploadTask.fromJson(Map json) => _$UploadTaskFromJson(json); @override final String id; @@ -245,6 +247,8 @@ class _UploadTask extends UploadTask { @override final UploadTaskStatus status; @override final DateTime createdAt; @override final DateTime updatedAt; +@override final double? transmissionProgress; +// Local file upload progress (0.0-1.0) @override final String? errorMessage; @override final SnCloudFile? result; @override final String? poolId; @@ -265,16 +269,16 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _UploadTask&&(identical(other.id, id) || other.id == id)&&(identical(other.taskId, taskId) || other.taskId == taskId)&&(identical(other.fileName, fileName) || other.fileName == fileName)&&(identical(other.contentType, contentType) || other.contentType == contentType)&&(identical(other.fileSize, fileSize) || other.fileSize == fileSize)&&(identical(other.uploadedBytes, uploadedBytes) || other.uploadedBytes == uploadedBytes)&&(identical(other.totalChunks, totalChunks) || other.totalChunks == totalChunks)&&(identical(other.uploadedChunks, uploadedChunks) || other.uploadedChunks == uploadedChunks)&&(identical(other.status, status) || other.status == status)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage)&&(identical(other.result, result) || other.result == result)&&(identical(other.poolId, poolId) || other.poolId == poolId)&&(identical(other.bundleId, bundleId) || other.bundleId == bundleId)&&(identical(other.encryptPassword, encryptPassword) || other.encryptPassword == encryptPassword)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _UploadTask&&(identical(other.id, id) || other.id == id)&&(identical(other.taskId, taskId) || other.taskId == taskId)&&(identical(other.fileName, fileName) || other.fileName == fileName)&&(identical(other.contentType, contentType) || other.contentType == contentType)&&(identical(other.fileSize, fileSize) || other.fileSize == fileSize)&&(identical(other.uploadedBytes, uploadedBytes) || other.uploadedBytes == uploadedBytes)&&(identical(other.totalChunks, totalChunks) || other.totalChunks == totalChunks)&&(identical(other.uploadedChunks, uploadedChunks) || other.uploadedChunks == uploadedChunks)&&(identical(other.status, status) || other.status == status)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.transmissionProgress, transmissionProgress) || other.transmissionProgress == transmissionProgress)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage)&&(identical(other.result, result) || other.result == result)&&(identical(other.poolId, poolId) || other.poolId == poolId)&&(identical(other.bundleId, bundleId) || other.bundleId == bundleId)&&(identical(other.encryptPassword, encryptPassword) || other.encryptPassword == encryptPassword)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,id,taskId,fileName,contentType,fileSize,uploadedBytes,totalChunks,uploadedChunks,status,createdAt,updatedAt,errorMessage,result,poolId,bundleId,encryptPassword,expiredAt); +int get hashCode => Object.hash(runtimeType,id,taskId,fileName,contentType,fileSize,uploadedBytes,totalChunks,uploadedChunks,status,createdAt,updatedAt,transmissionProgress,errorMessage,result,poolId,bundleId,encryptPassword,expiredAt); @override String toString() { - return 'UploadTask(id: $id, taskId: $taskId, fileName: $fileName, contentType: $contentType, fileSize: $fileSize, uploadedBytes: $uploadedBytes, totalChunks: $totalChunks, uploadedChunks: $uploadedChunks, status: $status, createdAt: $createdAt, updatedAt: $updatedAt, errorMessage: $errorMessage, result: $result, poolId: $poolId, bundleId: $bundleId, encryptPassword: $encryptPassword, expiredAt: $expiredAt)'; + return 'UploadTask(id: $id, taskId: $taskId, fileName: $fileName, contentType: $contentType, fileSize: $fileSize, uploadedBytes: $uploadedBytes, totalChunks: $totalChunks, uploadedChunks: $uploadedChunks, status: $status, createdAt: $createdAt, updatedAt: $updatedAt, transmissionProgress: $transmissionProgress, errorMessage: $errorMessage, result: $result, poolId: $poolId, bundleId: $bundleId, encryptPassword: $encryptPassword, expiredAt: $expiredAt)'; } @@ -285,7 +289,7 @@ abstract mixin class _$UploadTaskCopyWith<$Res> implements $UploadTaskCopyWith<$ factory _$UploadTaskCopyWith(_UploadTask value, $Res Function(_UploadTask) _then) = __$UploadTaskCopyWithImpl; @override @useResult $Res call({ - String id, String taskId, String fileName, String contentType, int fileSize, int uploadedBytes, int totalChunks, int uploadedChunks, UploadTaskStatus status, DateTime createdAt, DateTime updatedAt, String? errorMessage, SnCloudFile? result, String? poolId, String? bundleId, String? encryptPassword, String? expiredAt + String id, String taskId, String fileName, String contentType, int fileSize, int uploadedBytes, int totalChunks, int uploadedChunks, UploadTaskStatus status, DateTime createdAt, DateTime updatedAt, double? transmissionProgress, String? errorMessage, SnCloudFile? result, String? poolId, String? bundleId, String? encryptPassword, String? expiredAt }); @@ -302,7 +306,7 @@ class __$UploadTaskCopyWithImpl<$Res> /// Create a copy of UploadTask /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? taskId = null,Object? fileName = null,Object? contentType = null,Object? fileSize = null,Object? uploadedBytes = null,Object? totalChunks = null,Object? uploadedChunks = null,Object? status = null,Object? createdAt = null,Object? updatedAt = null,Object? errorMessage = freezed,Object? result = freezed,Object? poolId = freezed,Object? bundleId = freezed,Object? encryptPassword = freezed,Object? expiredAt = freezed,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? taskId = null,Object? fileName = null,Object? contentType = null,Object? fileSize = null,Object? uploadedBytes = null,Object? totalChunks = null,Object? uploadedChunks = null,Object? status = null,Object? createdAt = null,Object? updatedAt = null,Object? transmissionProgress = freezed,Object? errorMessage = freezed,Object? result = freezed,Object? poolId = freezed,Object? bundleId = freezed,Object? encryptPassword = freezed,Object? expiredAt = freezed,}) { return _then(_UploadTask( id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable as String,taskId: null == taskId ? _self.taskId : taskId // ignore: cast_nullable_to_non_nullable @@ -315,7 +319,8 @@ as int,uploadedChunks: null == uploadedChunks ? _self.uploadedChunks : uploadedC as int,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable as UploadTaskStatus,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable -as DateTime,errorMessage: freezed == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable +as DateTime,transmissionProgress: freezed == transmissionProgress ? _self.transmissionProgress : transmissionProgress // ignore: cast_nullable_to_non_nullable +as double?,errorMessage: freezed == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable as String?,result: freezed == result ? _self.result : result // ignore: cast_nullable_to_non_nullable as SnCloudFile?,poolId: freezed == poolId ? _self.poolId : poolId // ignore: cast_nullable_to_non_nullable as String?,bundleId: freezed == bundleId ? _self.bundleId : bundleId // ignore: cast_nullable_to_non_nullable diff --git a/lib/models/upload_task.g.dart b/lib/models/upload_task.g.dart index 06b49f6a..fa215ec9 100644 --- a/lib/models/upload_task.g.dart +++ b/lib/models/upload_task.g.dart @@ -18,6 +18,7 @@ _UploadTask _$UploadTaskFromJson(Map json) => _UploadTask( status: $enumDecode(_$UploadTaskStatusEnumMap, json['status']), createdAt: DateTime.parse(json['created_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String), + transmissionProgress: (json['transmission_progress'] as num?)?.toDouble(), errorMessage: json['error_message'] as String?, result: json['result'] == null @@ -42,6 +43,7 @@ Map _$UploadTaskToJson(_UploadTask instance) => 'status': _$UploadTaskStatusEnumMap[instance.status]!, 'created_at': instance.createdAt.toIso8601String(), 'updated_at': instance.updatedAt.toIso8601String(), + 'transmission_progress': instance.transmissionProgress, 'error_message': instance.errorMessage, 'result': instance.result?.toJson(), 'pool_id': instance.poolId, diff --git a/lib/pods/chat/messages_notifier.dart b/lib/pods/chat/messages_notifier.dart index b725342e..f5f59477 100644 --- a/lib/pods/chat/messages_notifier.dart +++ b/lib/pods/chat/messages_notifier.dart @@ -3,6 +3,7 @@ import "package:dio/dio.dart"; import "package:drift/drift.dart" show Variable; import "package:easy_localization/easy_localization.dart"; import "package:flutter/material.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:island/database/drift_db.dart"; import "package:island/database/message.dart"; import "package:island/models/chat.dart"; @@ -433,6 +434,7 @@ class MessagesNotifier extends _$MessagesNotifier { } Future sendMessage( + WidgetRef ref, String content, List attachments, { SnChatMessage? editingTo, @@ -471,8 +473,8 @@ class MessagesNotifier extends _$MessagesNotifier { for (var idx = 0; idx < attachments.length; idx++) { final cloudFile = await FileUploader.createCloudFile( + ref: ref, fileData: attachments[idx], - client: ref.read(apiClientProvider), onProgress: (progress, _) { _fileUploadProgress[localMessage.id]?[idx] = progress ?? 0.0; onProgress?.call( diff --git a/lib/pods/upload_tasks.dart b/lib/pods/upload_tasks.dart index 1e65b952..22e19fc7 100644 --- a/lib/pods/upload_tasks.dart +++ b/lib/pods/upload_tasks.dart @@ -7,6 +7,7 @@ import 'package:island/models/upload_task.dart'; import 'package:island/pods/network.dart'; import 'package:island/pods/websocket.dart'; import 'package:island/services/file_uploader.dart'; +import 'package:island/talker.dart'; final uploadTasksProvider = StateNotifierProvider>( @@ -16,6 +17,7 @@ final uploadTasksProvider = class UploadTasksNotifier extends StateNotifier> { final Ref ref; StreamSubscription? _websocketSubscription; + final Map> _pendingUploads = {}; UploadTasksNotifier(this.ref) : super([]) { _listenToWebSocket(); @@ -29,40 +31,121 @@ class UploadTasksNotifier extends StateNotifier> { } void _handleWebSocketPacket(dynamic packet) { - if (packet.type.startsWith('upload.')) { + if (packet.type.startsWith('task.')) { final data = packet.data; if (data == null) return; + // Debug logging + talker.info( + '[UploadTasks] Received WebSocket packet: ${packet.type}, data: $data', + ); + final taskId = data['task_id'] as String?; if (taskId == null) return; switch (packet.type) { - case 'upload.progress': + case 'task.created': + _handleTaskCreated(taskId, data); + break; + case 'task.progress': _handleProgressUpdate(taskId, data); break; - case 'upload.completed': + case 'task.completed': _handleUploadCompleted(taskId, data); break; - case 'upload.failed': + case 'task.failed': _handleUploadFailed(taskId, data); break; } } } + void _handleTaskCreated(String taskId, Map data) { + talker.info('[UploadTasks] Handling task.created for taskId: $taskId'); + + // Check if task already exists (might have been created locally) + final existingTask = + state.where((task) => task.taskId == taskId).firstOrNull; + if (existingTask != null) { + talker.info('[UploadTasks] Task already exists, updating status'); + // Task already exists, just update its status to confirm server creation + state = + state.map((task) { + if (task.taskId == taskId) { + return task.copyWith( + status: UploadTaskStatus.pending, + updatedAt: DateTime.now(), + ); + } + return task; + }).toList(); + return; + } + + // Check if we have stored metadata for this task + final metadata = _pendingUploads[taskId]; + talker.info('[UploadTasks] Metadata for taskId $taskId: $metadata'); + + if (metadata != null) { + talker.info('[UploadTasks] Creating task with full metadata'); + // Create task with full metadata + final uploadTask = UploadTask( + id: DateTime.now().millisecondsSinceEpoch.toString(), + taskId: taskId, + fileName: metadata['fileName'] as String, + contentType: metadata['contentType'] as String, + fileSize: metadata['fileSize'] as int, + uploadedBytes: 0, + totalChunks: metadata['totalChunks'] as int, + uploadedChunks: 0, + status: UploadTaskStatus.pending, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + poolId: metadata['poolId'] as String?, + bundleId: metadata['bundleId'] as String?, + encryptPassword: metadata['encryptPassword'] as String?, + expiredAt: metadata['expiredAt'] as String?, + ); + + state = [...state, uploadTask]; + talker.info( + '[UploadTasks] Task created successfully. Total tasks: ${state.length}', + ); + // Clean up stored metadata + _pendingUploads.remove(taskId); + } else { + talker.info('[UploadTasks] No metadata found, creating minimal task'); + // Create minimal task if no metadata is stored + final uploadTask = UploadTask( + id: DateTime.now().millisecondsSinceEpoch.toString(), + taskId: taskId, + fileName: data['name'] as String? ?? 'Unknown file', + contentType: 'application/octet-stream', + fileSize: 0, + uploadedBytes: 0, + totalChunks: 0, + uploadedChunks: 0, + status: UploadTaskStatus.pending, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ); + + state = [...state, uploadTask]; + talker.info( + '[UploadTasks] Minimal task created. Total tasks: ${state.length}', + ); + } + } + void _handleProgressUpdate(String taskId, Map data) { - final uploadedChunks = data['chunksUploaded'] as int? ?? 0; - final uploadedBytes = - (data['progress'] as num? ?? 0.0) / - 100.0 * - (data['fileSize'] as int? ?? 0); + final progress = data['progress'] as num? ?? 0.0; state = state.map((task) { if (task.taskId == taskId) { + final uploadedBytes = (progress / 100.0 * task.fileSize).toInt(); return task.copyWith( - uploadedChunks: uploadedChunks, - uploadedBytes: uploadedBytes.toInt(), + uploadedBytes: uploadedBytes, status: UploadTaskStatus.inProgress, updatedAt: DateTime.now(), ); @@ -72,11 +155,7 @@ class UploadTasksNotifier extends StateNotifier> { } void _handleUploadCompleted(String taskId, Map data) { - final fileData = data['file']; - if (fileData != null) { - // Assuming the file data comes in the expected format - // You might need to adjust this based on the actual API response - } + final results = data['results'] as Map?; state = state.map((task) { @@ -85,6 +164,14 @@ class UploadTasksNotifier extends StateNotifier> { status: UploadTaskStatus.completed, uploadedChunks: task.totalChunks, uploadedBytes: task.fileSize, + // Update file information from Results if available + fileName: results?['file_name'] as String? ?? task.fileName, + fileSize: results?['file_size'] as int? ?? task.fileSize, + contentType: results?['mime_type'] as String? ?? task.contentType, + result: + results?['file_info'] != null + ? SnCloudFile.fromJson(results!['file_info']) + : null, updatedAt: DateTime.now(), ); } @@ -93,7 +180,7 @@ class UploadTasksNotifier extends StateNotifier> { } void _handleUploadFailed(String taskId, Map data) { - final errorMessage = data['error'] as String? ?? 'Upload failed'; + final errorMessage = data['error_message'] as String? ?? 'Upload failed'; state = state.map((task) { @@ -112,6 +199,29 @@ class UploadTasksNotifier extends StateNotifier> { state = [...state, task]; } + void storeUploadMetadata( + String taskId, { + required String fileName, + required String contentType, + required int fileSize, + required int totalChunks, + String? poolId, + String? bundleId, + String? encryptPassword, + String? expiredAt, + }) { + _pendingUploads[taskId] = { + 'fileName': fileName, + 'contentType': contentType, + 'fileSize': fileSize, + 'totalChunks': totalChunks, + 'poolId': poolId, + 'bundleId': bundleId, + 'encryptPassword': encryptPassword, + 'expiredAt': expiredAt, + }; + } + void updateTaskStatus( String taskId, UploadTaskStatus status, { @@ -130,10 +240,36 @@ class UploadTasksNotifier extends StateNotifier> { }).toList(); } + void updateTransmissionProgress(String taskId, double progress) { + state = + state.map((task) { + if (task.taskId == taskId) { + return task.copyWith( + transmissionProgress: progress, + updatedAt: DateTime.now(), + ); + } + return task; + }).toList(); + } + void removeTask(String taskId) { state = state.where((task) => task.taskId != taskId).toList(); } + void clearCompletedTasks() { + state = + state + .where( + (task) => + task.status != UploadTaskStatus.completed && + task.status != UploadTaskStatus.failed && + task.status != UploadTaskStatus.cancelled && + task.status != UploadTaskStatus.expired, + ) + .toList(); + } + UploadTask? getTask(String taskId) { return state.where((task) => task.taskId == taskId).firstOrNull; } @@ -144,7 +280,8 @@ class UploadTasksNotifier extends StateNotifier> { (task) => task.status == UploadTaskStatus.pending || task.status == UploadTaskStatus.inProgress || - task.status == UploadTaskStatus.paused, + task.status == UploadTaskStatus.paused || + task.status == UploadTaskStatus.completed, ) .toList(); } @@ -222,14 +359,6 @@ class EnhancedFileUploader extends FileUploader { chunkSize: customChunkSize, ); - if (createResponse['file_exists'] == true) { - // File already exists, return the existing file - return SnCloudFile.fromJson(createResponse['file']); - } - - final taskId = createResponse['task_id'] as String; - final chunkSize = createResponse['chunk_size'] as int; - final chunksCount = createResponse['chunks_count'] as int; int totalSize; if (fileData is XFile) { totalSize = await fileData.length(); @@ -239,26 +368,58 @@ class EnhancedFileUploader extends FileUploader { throw ArgumentError('Invalid fileData type'); } - // Create upload task and add to state - final uploadTask = UploadTask( - id: DateTime.now().millisecondsSinceEpoch.toString(), - taskId: taskId, - fileName: fileName, - contentType: contentType, - fileSize: totalSize, - uploadedBytes: 0, - totalChunks: chunksCount, - uploadedChunks: 0, - status: UploadTaskStatus.pending, - createdAt: DateTime.now(), - updatedAt: DateTime.now(), - poolId: poolId, - bundleId: bundleId, - encryptPassword: encryptPassword, - expiredAt: expiredAt, - ); + if (createResponse['file_exists'] == true) { + // File already exists, create a local task to show it was found + final existingFile = SnCloudFile.fromJson(createResponse['file']); - ref.read(uploadTasksProvider.notifier).addUploadTask(uploadTask); + // Create a task that shows as completed immediately + // Use a generated taskId since the server might not provide one for existing files + final taskId = + createResponse['task_id'] as String? ?? + 'existing-${DateTime.now().millisecondsSinceEpoch}'; + + final uploadTask = UploadTask( + id: DateTime.now().millisecondsSinceEpoch.toString(), + taskId: taskId, + fileName: fileName, + contentType: contentType, + fileSize: totalSize, + uploadedBytes: totalSize, + totalChunks: 1, // For existing files, we consider it as 1 chunk + uploadedChunks: 1, + status: UploadTaskStatus.completed, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + poolId: poolId, + bundleId: bundleId, + encryptPassword: encryptPassword, + expiredAt: expiredAt, + ); + + ref.read(uploadTasksProvider.notifier).addUploadTask(uploadTask); + + return existingFile; + } + + final taskId = createResponse['task_id'] as String; + final chunkSize = createResponse['chunk_size'] as int; + final chunksCount = createResponse['chunks_count'] as int; + + // Store upload metadata for when task.created event arrives + talker.info('[UploadTasks] Storing metadata for taskId: $taskId'); + ref + .read(uploadTasksProvider.notifier) + .storeUploadMetadata( + taskId, + fileName: fileName, + contentType: contentType, + fileSize: totalSize, + totalChunks: chunksCount, + poolId: poolId, + bundleId: bundleId, + encryptPassword: encryptPassword, + expiredAt: expiredAt, + ); // Step 2: Upload chunks int bytesUploaded = 0; @@ -279,6 +440,10 @@ class EnhancedFileUploader extends FileUploader { onSendProgress: (sent, total) { final overallProgress = (bytesUploaded + sent) / totalSize; onProgress?.call(overallProgress, Duration.zero); + // Update transmission progress in UI + ref + .read(uploadTasksProvider.notifier) + .updateTransmissionProgress(taskId, overallProgress); }, ); bytesUploaded += chunkData.length; @@ -302,6 +467,10 @@ class EnhancedFileUploader extends FileUploader { onSendProgress: (sent, total) { final overallProgress = (bytesUploaded + sent) / totalSize; onProgress?.call(overallProgress, Duration.zero); + // Update transmission progress in UI + ref + .read(uploadTasksProvider.notifier) + .updateTransmissionProgress(taskId, overallProgress); }, ); bytesUploaded += chunks[i].length; diff --git a/lib/screens/account/me/profile_update.dart b/lib/screens/account/me/profile_update.dart index fe440fdc..d70940ed 100644 --- a/lib/screens/account/me/profile_update.dart +++ b/lib/screens/account/me/profile_update.dart @@ -76,7 +76,7 @@ class UpdateProfileScreen extends HookConsumerWidget { try { final cloudFile = await FileUploader.createCloudFile( - client: ref.read(apiClientProvider), + ref: ref, fileData: UniversalFile( data: result, type: UniversalFileType.image, diff --git a/lib/screens/chat/chat_form.dart b/lib/screens/chat/chat_form.dart index 2fa92daa..1148e592 100644 --- a/lib/screens/chat/chat_form.dart +++ b/lib/screens/chat/chat_form.dart @@ -99,7 +99,7 @@ class EditChatScreen extends HookConsumerWidget { try { final cloudFile = await FileUploader.createCloudFile( - client: ref.read(apiClientProvider), + ref: ref, fileData: UniversalFile( data: result, type: UniversalFileType.image, diff --git a/lib/screens/chat/room.dart b/lib/screens/chat/room.dart index 0a85a3b0..dd94c020 100644 --- a/lib/screens/chat/room.dart +++ b/lib/screens/chat/room.dart @@ -265,6 +265,7 @@ class ChatRoomScreen extends HookConsumerWidget { if (messageController.text.trim().isNotEmpty || attachments.value.isNotEmpty) { messagesNotifier.sendMessage( + ref, messageController.text.trim(), attachments.value, editingTo: messageEditingTo.value, @@ -561,7 +562,7 @@ class ChatRoomScreen extends HookConsumerWidget { final cloudFile = await FileUploader.createCloudFile( - client: ref.read(apiClientProvider), + ref: ref, fileData: attachment, poolId: config.poolId, mode: diff --git a/lib/screens/creators/publishers_form.dart b/lib/screens/creators/publishers_form.dart index b3a30930..f64fff0f 100644 --- a/lib/screens/creators/publishers_form.dart +++ b/lib/screens/creators/publishers_form.dart @@ -95,11 +95,11 @@ class EditPublisherScreen extends HookConsumerWidget { try { final cloudFile = await FileUploader.createCloudFile( + ref: ref, fileData: UniversalFile( data: result, type: UniversalFileType.image, ), - client: ref.read(apiClientProvider), ).future; if (cloudFile == null) { throw ArgumentError('Failed to upload the file...'); diff --git a/lib/screens/developers/edit_app.dart b/lib/screens/developers/edit_app.dart index 0763ad61..da7d7caa 100644 --- a/lib/screens/developers/edit_app.dart +++ b/lib/screens/developers/edit_app.dart @@ -141,7 +141,7 @@ class EditAppScreen extends HookConsumerWidget { try { final cloudFile = await FileUploader.createCloudFile( - client: ref.read(apiClientProvider), + ref: ref, fileData: UniversalFile( data: result, type: UniversalFileType.image, diff --git a/lib/screens/developers/edit_bot.dart b/lib/screens/developers/edit_bot.dart index c23e54b0..23f47749 100644 --- a/lib/screens/developers/edit_bot.dart +++ b/lib/screens/developers/edit_bot.dart @@ -127,11 +127,11 @@ class EditBotScreen extends HookConsumerWidget { try { final cloudFile = await FileUploader.createCloudFile( + ref: ref, fileData: UniversalFile( data: result, type: UniversalFileType.image, ), - client: ref.read(apiClientProvider), ).future; if (cloudFile == null) { throw ArgumentError('Failed to upload the file...'); diff --git a/lib/screens/realm/realm_form.dart b/lib/screens/realm/realm_form.dart index 79ed768f..07d75846 100644 --- a/lib/screens/realm/realm_form.dart +++ b/lib/screens/realm/realm_form.dart @@ -92,7 +92,7 @@ class EditRealmScreen extends HookConsumerWidget { try { final cloudFile = await FileUploader.createCloudFile( - client: ref.read(apiClientProvider), + ref: ref, fileData: UniversalFile( data: result, type: UniversalFileType.image, diff --git a/lib/services/file_uploader.dart b/lib/services/file_uploader.dart index 34fbe771..536aebb0 100644 --- a/lib/services/file_uploader.dart +++ b/lib/services/file_uploader.dart @@ -7,6 +7,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:island/models/file.dart'; import 'package:island/pods/network.dart'; +import 'package:island/pods/upload_tasks.dart'; import 'package:mime/mime.dart'; import 'package:native_exif/native_exif.dart'; import 'package:path/path.dart' show extension; @@ -235,7 +236,7 @@ class FileUploader { static Completer createCloudFile({ required UniversalFile fileData, - required Dio client, + required WidgetRef ref, String? poolId, FileUploadMode? mode, Function(double? progress, Duration estimate)? onProgress, @@ -272,19 +273,14 @@ class FileUploader { await exif.writeAttributes(gpsAttributes); }) .then( - (_) => _processUploadWithEnhancedUploader( - fileData, - client, - poolId, - onProgress, - completer, - ), + (_) => + _processUpload(fileData, ref, poolId, onProgress, completer), ) .catchError((e) { debugPrint('Error removing GPS EXIF data: $e'); - return _processUploadWithEnhancedUploader( + return _processUpload( fileData, - client, + ref, poolId, onProgress, completer, @@ -295,20 +291,14 @@ class FileUploader { } } - _processUploadWithEnhancedUploader( - fileData, - client, - poolId, - onProgress, - completer, - ); + _processUpload(fileData, ref, poolId, onProgress, completer); return completer; } // Helper method to process the upload with enhanced uploader - static Completer _processUploadWithEnhancedUploader( + static Completer _processUpload( UniversalFile fileData, - Dio client, + WidgetRef ref, String? poolId, Function(double? progress, Duration estimate)? onProgress, Completer completer, @@ -321,11 +311,11 @@ class FileUploader { final data = fileData.data; if (data is XFile) { - _performUploadWithEnhancedUploader( + _performUpload( fileData: data, fileName: fileData.displayName ?? data.name, contentType: actualMimetype, - client: client, + ref: ref, poolId: poolId, onProgress: onProgress, completer: completer, @@ -348,11 +338,11 @@ class FileUploader { } if (bytes != null) { - _performUploadWithEnhancedUploader( + _performUpload( fileData: bytes, fileName: actualFilename, contentType: actualMimetype, - client: client, + ref: ref, poolId: poolId, onProgress: onProgress, completer: completer, @@ -362,17 +352,18 @@ class FileUploader { return completer; } - // Helper method to perform the actual upload + // Helper method to perform the actual upload with enhanced uploader static void _performUpload({ required dynamic fileData, required String fileName, required String contentType, - required Dio client, + required WidgetRef ref, String? poolId, Function(double? progress, Duration estimate)? onProgress, required Completer completer, }) { - final uploader = FileUploader(client); + // Use the enhanced uploader with task tracking + final uploader = ref.read(enhancedFileUploaderProvider); // Call progress start onProgress?.call(null, Duration.zero); @@ -395,30 +386,6 @@ class FileUploader { }); } - // Helper method to perform the actual upload with enhanced uploader - static void _performUploadWithEnhancedUploader({ - required dynamic fileData, - required String fileName, - required String contentType, - required Dio client, - String? poolId, - Function(double? progress, Duration estimate)? onProgress, - required Completer completer, - }) { - // Use the enhanced uploader from Riverpod context - // This will be called from a context where we have access to Riverpod - // For now, fall back to the regular uploader - _performUpload( - fileData: fileData, - fileName: fileName, - contentType: contentType, - client: client, - poolId: poolId, - onProgress: onProgress, - completer: completer, - ); - } - /// Gets the MIME type of a UniversalFile. static String getMimeType(UniversalFile file, {bool useFallback = true}) { final data = file.data; diff --git a/lib/widgets/content/attachment_preview.dart b/lib/widgets/content/attachment_preview.dart index 677e040a..6a465b80 100644 --- a/lib/widgets/content/attachment_preview.dart +++ b/lib/widgets/content/attachment_preview.dart @@ -401,7 +401,7 @@ class AttachmentPreview extends HookConsumerWidget { children: [ if (progress != null) Text( - '${progress!.toStringAsFixed(2)}%', + '${(progress! * 100).toStringAsFixed(2)}%', style: TextStyle(color: Colors.white), ) else diff --git a/lib/widgets/content/cloud_file_picker.dart b/lib/widgets/content/cloud_file_picker.dart index 757b1dce..0521afbc 100644 --- a/lib/widgets/content/cloud_file_picker.dart +++ b/lib/widgets/content/cloud_file_picker.dart @@ -6,7 +6,6 @@ import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:image_picker/image_picker.dart'; import 'package:island/models/file.dart'; -import 'package:island/pods/network.dart'; import 'package:island/services/file_uploader.dart'; import 'package:island/widgets/alert.dart'; import 'package:island/widgets/content/attachment_preview.dart'; @@ -61,7 +60,7 @@ class CloudFilePicker extends HookConsumerWidget { final cloudFile = await FileUploader.createCloudFile( fileData: file, - client: ref.read(apiClientProvider), + ref: ref, onProgress: (progress, _) { uploadProgress.value = progress; }, diff --git a/lib/widgets/post/compose_shared.dart b/lib/widgets/post/compose_shared.dart index 5c824e38..eed6f224 100644 --- a/lib/widgets/post/compose_shared.dart +++ b/lib/widgets/post/compose_shared.dart @@ -180,7 +180,7 @@ class ComposeLogic { try { final cloudFile = await FileUploader.createCloudFile( - client: ref.read(apiClientProvider), + ref: ref, fileData: attachment, ).future; if (cloudFile != null) { @@ -510,7 +510,7 @@ class ComposeLogic { cloudFile = await FileUploader.createCloudFile( - client: ref.read(apiClientProvider), + ref: ref, fileData: attachment, poolId: poolId ?? selectedPoolId, mode: diff --git a/lib/widgets/share/share_sheet.dart b/lib/widgets/share/share_sheet.dart index 017ea203..d2c1cb23 100644 --- a/lib/widgets/share/share_sheet.dart +++ b/lib/widgets/share/share_sheet.dart @@ -241,7 +241,7 @@ class _ShareSheetState extends ConsumerState { final file = universalFiles[idx]; final cloudFile = await FileUploader.createCloudFile( - client: apiClient, + ref: ref, fileData: file, onProgress: (progress, _) { if (mounted) { diff --git a/lib/widgets/upload_overlay.dart b/lib/widgets/upload_overlay.dart index ca591409..0a5a1a05 100644 --- a/lib/widgets/upload_overlay.dart +++ b/lib/widgets/upload_overlay.dart @@ -18,81 +18,279 @@ class UploadOverlay extends HookConsumerWidget { (task) => task.status == UploadTaskStatus.pending || task.status == UploadTaskStatus.inProgress || - task.status == UploadTaskStatus.paused, + task.status == UploadTaskStatus.paused || + task.status == UploadTaskStatus.completed, ) .toList(); + // if (activeTasks.isEmpty) { + // return const SizedBox.shrink(); + // } - if (activeTasks.isEmpty) { - return const SizedBox.shrink(); - } + return _UploadOverlayContent(activeTasks: activeTasks); + } +} + +class _UploadOverlayContent extends HookConsumerWidget { + final List activeTasks; + + const _UploadOverlayContent({required this.activeTasks}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isExpanded = useState(false); + final animationController = useAnimationController( + duration: const Duration(milliseconds: 200), + initialValue: 0.0, + ); + final heightAnimation = useAnimation( + Tween(begin: 60, end: 400).animate( + CurvedAnimation(parent: animationController, curve: Curves.easeInOut), + ), + ); + final opacityAnimation = useAnimation( + CurvedAnimation(parent: animationController, curve: Curves.easeInOut), + ); + + useEffect(() { + if (isExpanded.value) { + animationController.forward(); + } else { + animationController.reverse(); + } + return null; + }, [isExpanded.value]); + + final isMobile = MediaQuery.of(context).size.width < 600; return Positioned( - bottom: 16, - right: 16, - child: Material( - elevation: 8, - borderRadius: BorderRadius.circular(12), - color: Theme.of(context).colorScheme.surfaceContainer, - child: Container( - width: 320, - constraints: BoxConstraints(maxHeight: 400), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Header - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: Theme.of(context).dividerColor, - width: 1, - ), + bottom: isMobile ? 16 : 24, + left: isMobile ? 16 : null, + right: isMobile ? 16 : 24, + child: GestureDetector( + onTap: () => isExpanded.value = !isExpanded.value, + child: AnimatedBuilder( + animation: animationController, + builder: (context, child) { + return Material( + elevation: 8 + (opacityAnimation * 4), + borderRadius: BorderRadius.circular(12), + color: Theme.of(context).colorScheme.surfaceContainer, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + width: isMobile ? MediaQuery.of(context).size.width - 32 : 320, + height: heightAnimation, + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Collapsed Header + Container( + height: 60, + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + // Upload icon with animation + AnimatedSwitcher( + duration: const Duration(milliseconds: 150), + transitionBuilder: (child, animation) { + return FadeTransition( + opacity: animation, + child: child, + ); + }, + child: Icon( + key: ValueKey(isExpanded.value), + isExpanded.value + ? Symbols.expand_more + : Symbols.upload, + size: 24, + color: Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(width: 12), + + // Title and count + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + isExpanded.value + ? 'uploadTasks'.tr() + : '${activeTasks.length} ${'uploading'.tr()}', + style: Theme.of(context) + .textTheme + .titleSmall + ?.copyWith(fontWeight: FontWeight.w600), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (!isExpanded.value && + activeTasks.isNotEmpty) + Text( + _getOverallProgressText(activeTasks), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith( + color: + Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + + // Progress indicator (collapsed) + if (!isExpanded.value) + SizedBox( + width: 32, + height: 32, + child: CircularProgressIndicator( + value: _getOverallProgress(activeTasks), + strokeWidth: 3, + backgroundColor: + Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + ), + ), + + // Expand/collapse button + IconButton( + icon: AnimatedRotation( + turns: opacityAnimation * 0.5, + duration: const Duration(milliseconds: 200), + child: Icon( + isExpanded.value + ? Symbols.expand_more + : Symbols.chevron_right, + size: 20, + ), + ), + onPressed: + () => isExpanded.value = !isExpanded.value, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + ), + + // Expanded content + if (isExpanded.value) + Expanded( + child: Container( + decoration: BoxDecoration( + border: Border( + top: BorderSide( + color: Theme.of(context).dividerColor, + width: 1, + ), + ), + ), + child: Column( + children: [ + // Clear completed tasks button + if (_hasCompletedTasks(activeTasks)) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: Theme.of(context).dividerColor, + width: 1, + ), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton.icon( + onPressed: () { + ref + .read( + uploadTasksProvider.notifier, + ) + .clearCompletedTasks(); + }, + icon: Icon( + Symbols.clear_all, + size: 18, + ), + label: const Text('Clear Completed'), + style: TextButton.styleFrom( + foregroundColor: + Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + + // Task list + Expanded( + child: AnimatedOpacity( + opacity: opacityAnimation, + duration: const Duration(milliseconds: 150), + child: ListView.builder( + shrinkWrap: true, + padding: EdgeInsets.zero, + itemCount: activeTasks.length, + itemBuilder: (context, index) { + final task = activeTasks[index]; + return UploadTaskTile(task: task); + }, + ), + ), + ), + ], + ), + ), + ), + ], ), ), - child: Row( - children: [ - Icon( - Symbols.upload, - size: 20, - color: Theme.of(context).colorScheme.primary, - ), - const SizedBox(width: 8), - Text( - 'uploadTasks'.tr(), - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const Spacer(), - Text( - '${activeTasks.length}', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.w600, - ), - ), - ], - ), ), - - // Task list - Flexible( - child: ListView.builder( - shrinkWrap: true, - itemCount: activeTasks.length, - itemBuilder: (context, index) { - final task = activeTasks[index]; - return UploadTaskTile(task: task); - }, - ), - ), - ], - ), + ); + }, ), ), ); } + + double _getOverallProgress(List tasks) { + if (tasks.isEmpty) return 0.0; + final totalProgress = tasks.fold( + 0.0, + (sum, task) => sum + task.progress, + ); + return totalProgress / tasks.length; + } + + String _getOverallProgressText(List tasks) { + final overallProgress = _getOverallProgress(tasks); + return '${(overallProgress * 100).toStringAsFixed(0)}%'; + } + + bool _hasCompletedTasks(List tasks) { + return tasks.any( + (task) => + task.status == UploadTaskStatus.completed || + task.status == UploadTaskStatus.failed || + task.status == UploadTaskStatus.cancelled || + task.status == UploadTaskStatus.expired, + ); + } } class UploadTaskTile extends HookConsumerWidget { @@ -219,6 +417,8 @@ class UploadTaskTile extends HookConsumerWidget { } Widget _buildExpandedDetails(BuildContext context) { + final transmissionProgress = task.transmissionProgress ?? 0.0; + return Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( @@ -228,7 +428,15 @@ class UploadTaskTile extends HookConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Progress text + // Server Processing Progress + Text( + 'Server Processing', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(height: 2), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -244,10 +452,7 @@ class UploadTaskTile extends HookConsumerWidget { ), ], ), - const SizedBox(height: 4), - - // Progress bar LinearProgressIndicator( value: task.progress, backgroundColor: Theme.of(context).colorScheme.surface, @@ -256,6 +461,41 @@ class UploadTaskTile extends HookConsumerWidget { ), ), + const SizedBox(height: 8), + + // File Transmission Progress + Text( + 'File Transmission', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.secondary, + ), + ), + const SizedBox(height: 2), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${(transmissionProgress * 100).toStringAsFixed(1)}%', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600), + ), + Text( + '${_formatFileSize((transmissionProgress * task.fileSize).toInt())} / ${_formatFileSize(task.fileSize)}', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + const SizedBox(height: 4), + LinearProgressIndicator( + value: transmissionProgress, + backgroundColor: Theme.of(context).colorScheme.surface, + valueColor: AlwaysStoppedAnimation( + Theme.of(context).colorScheme.secondary, + ), + ), + const SizedBox(height: 4), // Speed and ETA