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 <kimura@texas0295.top>
This commit is contained in:
Texas0295
2025-09-21 14:44:18 +08:00
parent 406e5187a8
commit 269a64cabb
6 changed files with 130 additions and 24 deletions

View File

@@ -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",

View File

@@ -122,6 +122,7 @@
"addVideo": "添加视频",
"addPhoto": "添加照片",
"addFile": "添加文件",
"uploadFile": "上传文件",
"createDirectMessage": "创建新私人消息",
"gotoDirectMessage": "前往私信",
"react": "反应",

View File

@@ -122,6 +122,7 @@
"addVideo": "添加視頻",
"addPhoto": "添加照片",
"addFile": "添加文件",
"uploadFile": "上傳文件",
"createDirectMessage": "創建新私人消息",
"gotoDirectMessage": "前往私信",
"react": "反應",

View File

@@ -40,6 +40,55 @@ Future<XFile?> cropImage(
);
}
Completer<SnCloudFile?> 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<SnCloudFile?>();
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<SnCloudFile?> putMediaToCloud({
required UniversalFile fileData,
required String atk,

View File

@@ -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<void> 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 = <UniversalFile>[];
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<void> pickPhotoMedia(WidgetRef ref, ComposeState state) async {
final result = await FilePicker.platform.pickFiles(
type: FileType.image,
@@ -479,8 +504,9 @@ class ComposeLogic {
static Future<void> 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;

View File

@@ -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),