From 269a64cabbd8f70d53513cce881d5c3ab1599354 Mon Sep 17 00:00:00 2001 From: Texas0295 Date: Sun, 21 Sep 2025 14:44:18 +0800 Subject: [PATCH] add general file upload support with pool-aware tus client - add "uploadFile" i18n key (en, zh-CN, zh-TW) - introduce putFileToPool for tus upload with X-FilePool header - add ComposeLogic.pickGeneralFile for arbitrary files - extend uploadAttachment to support poolId override - add toolbar button for general file upload Signed-off-by: Texas0295 --- assets/i18n/en-US.json | 1 + assets/i18n/zh-CN.json | 1 + assets/i18n/zh-TW.json | 1 + lib/services/file.dart | 49 ++++++++++++++ lib/widgets/post/compose_shared.dart | 92 ++++++++++++++++++++------- lib/widgets/post/compose_toolbar.dart | 10 +++ 6 files changed, 130 insertions(+), 24 deletions(-) diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index 120b957d..df3e0db7 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -168,6 +168,7 @@ "addPhoto": "Add photo", "addAudio": "Add audio", "addFile": "Add file", + "uploadFile": "Upload File", "recordAudio": "Record Audio", "linkAttachment": "Link Attachment", "fileIdCannotBeEmpty": "File ID cannot be empty", diff --git a/assets/i18n/zh-CN.json b/assets/i18n/zh-CN.json index c4d307e9..91bae0c4 100644 --- a/assets/i18n/zh-CN.json +++ b/assets/i18n/zh-CN.json @@ -122,6 +122,7 @@ "addVideo": "添加视频", "addPhoto": "添加照片", "addFile": "添加文件", + "uploadFile": "上传文件", "createDirectMessage": "创建新私人消息", "gotoDirectMessage": "前往私信", "react": "反应", diff --git a/assets/i18n/zh-TW.json b/assets/i18n/zh-TW.json index d8d56cfe..8dac4c3e 100644 --- a/assets/i18n/zh-TW.json +++ b/assets/i18n/zh-TW.json @@ -122,6 +122,7 @@ "addVideo": "添加視頻", "addPhoto": "添加照片", "addFile": "添加文件", + "uploadFile": "上傳文件", "createDirectMessage": "創建新私人消息", "gotoDirectMessage": "前往私信", "react": "反應", diff --git a/lib/services/file.dart b/lib/services/file.dart index 4971a987..ffc26090 100644 --- a/lib/services/file.dart +++ b/lib/services/file.dart @@ -40,6 +40,55 @@ Future cropImage( ); } +Completer putFileToPool({ + required UniversalFile fileData, + required String atk, + required String baseUrl, + required String poolId, + String? filename, + String? mimetype, + Function(double progress, Duration estimate)? onProgress, +}) { + final completer = Completer(); + + final data = fileData.data; + if (data is! XFile) { + completer.completeError( + ArgumentError('Unsupported fileData type for putFileToPool'), + ); + return completer; + } + + final actualFilename = filename ?? data.name; + final actualMimetype = mimetype ?? data.mimeType ?? 'application/octet-stream'; + + final metadata = { + 'filename': actualFilename, + 'content-type': actualMimetype, + }; + + final client = TusClient(data); + client + .upload( + uri: Uri.parse('$baseUrl/drive/tus'), + headers: { + 'Authorization': 'AtField $atk', + 'X-FilePool': poolId, + }, + metadata: metadata, + onComplete: (lastResponse) { + final resp = jsonDecode(lastResponse!.headers['x-fileinfo']!); + completer.complete(SnCloudFile.fromJson(resp)); + }, + onProgress: (progress, est) { + onProgress?.call(progress, est); + }, + ) + .catchError(completer.completeError); + + return completer; +} + Completer putMediaToCloud({ required UniversalFile fileData, required String atk, diff --git a/lib/widgets/post/compose_shared.dart b/lib/widgets/post/compose_shared.dart index 84b36b16..1684ce9f 100644 --- a/lib/widgets/post/compose_shared.dart +++ b/lib/widgets/post/compose_shared.dart @@ -1,4 +1,5 @@ import 'package:collection/collection.dart'; +import 'package:mime/mime.dart'; import 'package:dio/dio.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:file_picker/file_picker.dart'; @@ -386,6 +387,30 @@ class ComposeLogic { }; } + static Future pickGeneralFile(WidgetRef ref, ComposeState state) async { + final result = await FilePicker.platform.pickFiles( + type: FileType.any, + allowMultiple: true, + ); + if (result == null || result.count == 0) return; + + final newFiles = []; + + for (final f in result.files) { + if (f.path == null) continue; + + final mimeType = + lookupMimeType(f.path!, headerBytes: f.bytes) ?? + 'application/octet-stream'; + final xfile = XFile(f.path!, name: f.name, mimeType: mimeType); + + final uf = UniversalFile(data: xfile, type: UniversalFileType.file); + newFiles.add(uf); + } + + state.attachments.value = [...state.attachments.value, ...newFiles]; + } + static Future pickPhotoMedia(WidgetRef ref, ComposeState state) async { final result = await FilePicker.platform.pickFiles( type: FileType.image, @@ -479,8 +504,9 @@ class ComposeLogic { static Future uploadAttachment( WidgetRef ref, ComposeState state, - int index, - ) async { + int index, { + String? poolId, + }) async { final attachment = state.attachments.value[index]; if (attachment.isOnCloud) return; @@ -489,42 +515,61 @@ class ComposeLogic { if (token == null) throw ArgumentError('Token is null'); try { - // Update progress state state.attachmentProgress.value = { ...state.attachmentProgress.value, index: 0, }; - // Upload file to cloud - final cloudFile = - await putMediaToCloud( - fileData: attachment, - atk: token, - baseUrl: baseUrl, - filename: attachment.data.name ?? 'Post media', - mimetype: - attachment.data.mimeType ?? - getMimeTypeFromFileType(attachment.type), - onProgress: (progress, _) { - state.attachmentProgress.value = { - ...state.attachmentProgress.value, - index: progress, - }; - }, - ).future; + SnCloudFile? cloudFile; + + if (attachment.type == UniversalFileType.file) { + cloudFile = + await putFileToPool( + fileData: attachment, + atk: token, + baseUrl: baseUrl, + // TODO: Generic Pool ID (Now: Solian Network Driver) + poolId: poolId ?? '500e5ed8-bd44-4359-bc0a-ec85e2adf447', + filename: attachment.data.name ?? 'General file', + mimetype: + attachment.data.mimeType ?? + getMimeTypeFromFileType(attachment.type), + onProgress: (progress, _) { + state.attachmentProgress.value = { + ...state.attachmentProgress.value, + index: progress, + }; + }, + ).future; + } else { + cloudFile = + await putMediaToCloud( + fileData: attachment, + atk: token, + baseUrl: baseUrl, + filename: attachment.data.name ?? 'Post media', + mimetype: + attachment.data.mimeType ?? + getMimeTypeFromFileType(attachment.type), + onProgress: (progress, _) { + state.attachmentProgress.value = { + ...state.attachmentProgress.value, + index: progress, + }; + }, + ).future; + } if (cloudFile == null) { throw ArgumentError('Failed to upload the file...'); } - // Update attachments list with cloud file final clone = List.of(state.attachments.value); clone[index] = UniversalFile(data: cloudFile, type: attachment.type); state.attachments.value = clone; } catch (err) { - showErrorAlert(err); + showErrorAlert(err.toString()); } finally { - // Clean up progress state state.attachmentProgress.value = {...state.attachmentProgress.value} ..remove(index); } @@ -643,7 +688,6 @@ class ComposeLogic { .where((entry) => entry.value.isOnDevice) .map((entry) => uploadAttachment(ref, state, entry.key)), ); - // Prepare API request final client = ref.watch(apiClientProvider); final isNewPost = originalPost == null; diff --git a/lib/widgets/post/compose_toolbar.dart b/lib/widgets/post/compose_toolbar.dart index 65d3a354..55745d37 100644 --- a/lib/widgets/post/compose_toolbar.dart +++ b/lib/widgets/post/compose_toolbar.dart @@ -25,6 +25,10 @@ class ComposeToolbar extends HookConsumerWidget { ComposeLogic.pickVideoMedia(ref, state); } + void pickGeneralFile() { + ComposeLogic.pickGeneralFile(ref, state); + } + void addAudio() { ComposeLogic.recordAudioMedia(ref, state, context); } @@ -96,6 +100,12 @@ class ComposeToolbar extends HookConsumerWidget { icon: const Icon(Symbols.mic), color: colorScheme.primary, ), + IconButton( + onPressed: pickGeneralFile, + tooltip: 'uploadFile'.tr(), + icon: const Icon(Symbols.file_upload), + color: colorScheme.primary, + ), IconButton( onPressed: linkAttachment, icon: const Icon(Symbols.attach_file),