From 02ec11845be33822794f4aa383a755e5c75c020c Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Wed, 24 Sep 2025 16:53:32 +0800 Subject: [PATCH] :sparkles: Seprate uploading action in chat --- lib/screens/chat/room.dart | 420 ++++++++++++++++++++++++++++++- lib/widgets/chat/chat_input.dart | 3 + 2 files changed, 420 insertions(+), 3 deletions(-) diff --git a/lib/screens/chat/room.dart b/lib/screens/chat/room.dart index 7f4d5266..3245d43a 100644 --- a/lib/screens/chat/room.dart +++ b/lib/screens/chat/room.dart @@ -1,5 +1,7 @@ import "dart:async"; import "dart:convert"; +import "dart:typed_data"; +import "package:cross_file/cross_file.dart"; import "package:easy_localization/easy_localization.dart"; import "package:file_picker/file_picker.dart"; import "package:flutter/material.dart"; @@ -10,16 +12,24 @@ import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:island/database/message.dart"; import "package:island/models/chat.dart"; import "package:island/models/file.dart"; +import "package:island/models/file_pool.dart"; +import "package:island/pods/config.dart"; +import "package:island/pods/file_pool.dart"; import "package:island/pods/messages_notifier.dart"; import "package:island/pods/network.dart"; import "package:island/pods/websocket.dart"; +import "package:island/services/file.dart"; import "package:island/screens/chat/chat.dart"; import "package:island/services/responsive.dart"; import "package:island/widgets/alert.dart"; import "package:island/widgets/app_scaffold.dart"; +import "package:island/widgets/attachment_uploader.dart"; import "package:island/widgets/chat/call_overlay.dart"; import "package:island/widgets/chat/message_item.dart"; +import "package:island/widgets/content/attachment_preview.dart"; import "package:island/widgets/content/cloud_files.dart"; +import "package:island/widgets/content/sheet.dart"; +import "package:island/widgets/post/compose_shared.dart"; import "package:island/widgets/response.dart"; import "package:material_symbols_icons/material_symbols_icons.dart"; import "package:styled_widget/styled_widget.dart"; @@ -464,6 +474,70 @@ class ChatRoomScreen extends HookConsumerWidget { const messageKeyPrefix = 'message-'; + Future uploadAttachment(int index) async { + final attachment = attachments.value[index]; + if (attachment.isOnCloud) return; + + final config = await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: + (context) => ChatAttachmentUploaderSheet( + ref: ref, + attachments: attachments.value, + index: index, + ), + ); + if (config == null) return; + + final baseUrl = ref.watch(serverUrlProvider); + final token = await getToken(ref.watch(tokenProvider)); + if (token == null) throw ArgumentError('Token is null'); + + try { + // Use 'chat-upload' as temporary key for progress + attachmentProgress.value = { + ...attachmentProgress.value, + 'chat-upload': {index: 0}, + }; + + final cloudFile = + await putFileToCloud( + fileData: attachment, + atk: token, + baseUrl: baseUrl, + poolId: config.poolId, + filename: attachment.data.name ?? 'Chat media', + mimetype: + attachment.data.mimeType ?? + ComposeLogic.getMimeTypeFromFileType(attachment.type), + mode: + attachment.type == UniversalFileType.file + ? FileUploadMode.generic + : FileUploadMode.mediaSafe, + onProgress: (progress, _) { + attachmentProgress.value = { + ...attachmentProgress.value, + 'chat-upload': {index: progress}, + }; + }, + ).future; + + if (cloudFile == null) { + throw ArgumentError('Failed to upload the file...'); + } + + final clone = List.of(attachments.value); + clone[index] = UniversalFile(data: cloudFile, type: attachment.type); + attachments.value = clone; + } catch (err) { + showErrorAlert(err.toString()); + } finally { + attachmentProgress.value = {...attachmentProgress.value} + ..remove('chat-upload'); + } + } + Widget chatMessageListWidget(List messageList) => SuperListView.builder( listController: listController, @@ -779,9 +853,7 @@ class ChatRoomScreen extends HookConsumerWidget { } }, attachments: attachments.value, - onUploadAttachment: (_) { - // not going to do anything, only upload when send the message - }, + onUploadAttachment: uploadAttachment, onDeleteAttachment: (index) async { final attachment = attachments.value[index]; if (attachment.isOnCloud) { @@ -806,6 +878,7 @@ class ChatRoomScreen extends HookConsumerWidget { onAttachmentsChanged: (newAttachments) { attachments.value = newAttachments; }, + attachmentProgress: attachmentProgress.value, ), ], ), @@ -825,3 +898,344 @@ class ChatRoomScreen extends HookConsumerWidget { ); } } + +class ChatAttachmentUploaderSheet extends StatefulWidget { + final WidgetRef ref; + final List attachments; + final int index; + + const ChatAttachmentUploaderSheet({ + super.key, + required this.ref, + required this.attachments, + required this.index, + }); + + @override + State createState() => + _ChatAttachmentUploaderSheetState(); +} + +class _ChatAttachmentUploaderSheetState + extends State { + String? selectedPoolId; + + @override + Widget build(BuildContext context) { + final attachment = widget.attachments[widget.index]; + + return SheetScaffold( + titleText: 'uploadAttachment'.tr(), + child: FutureBuilder>( + future: widget.ref.read(poolsProvider.future), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return Center(child: Text('errorLoadingPools'.tr())); + } + final pools = snapshot.data!.filterValid(); + selectedPoolId ??= resolveDefaultPoolId(widget.ref, pools); + + return Column( + children: [ + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DropdownButtonFormField( + value: selectedPoolId, + items: + pools.map((pool) { + return DropdownMenuItem( + value: pool.id, + child: Text(pool.name), + ); + }).toList(), + onChanged: (value) { + setState(() { + selectedPoolId = value; + }); + }, + decoration: InputDecoration( + labelText: 'selectPool'.tr(), + border: const OutlineInputBorder(), + hintText: 'choosePool'.tr(), + ), + ), + const Gap(16), + FutureBuilder( + future: _getFileSize(attachment), + builder: (context, sizeSnapshot) { + if (!sizeSnapshot.hasData) { + return const SizedBox.shrink(); + } + final fileSize = sizeSnapshot.data!; + final selectedPool = pools.firstWhere( + (p) => p.id == selectedPoolId, + ); + + // Check file size limit + final maxFileSize = + selectedPool.policyConfig?['max_file_size'] + as int?; + final fileSizeExceeded = + maxFileSize != null && fileSize > maxFileSize; + + // Check accepted types + final acceptTypes = + selectedPool.policyConfig?['accept_types'] + as List?; + final mimeType = + attachment.data.mimeType ?? + ComposeLogic.getMimeTypeFromFileType( + attachment.type, + ); + final typeAccepted = + acceptTypes == null || + acceptTypes.isEmpty || + acceptTypes.any( + (type) => mimeType.startsWith(type), + ); + + final hasIssues = fileSizeExceeded || !typeAccepted; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (hasIssues) ...[ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: + Theme.of( + context, + ).colorScheme.errorContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Symbols.warning, + size: 18, + color: + Theme.of( + context, + ).colorScheme.error, + ), + const Gap(8), + Text( + 'uploadConstraints'.tr(), + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith( + color: + Theme.of( + context, + ).colorScheme.error, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + if (fileSizeExceeded) ...[ + const Gap(4), + Text( + 'fileSizeExceeded'.tr( + args: [ + _formatFileSize(maxFileSize), + ], + ), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith( + color: + Theme.of( + context, + ).colorScheme.error, + ), + ), + ], + if (!typeAccepted) ...[ + const Gap(4), + Text( + 'fileTypeNotAccepted'.tr(), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith( + color: + Theme.of( + context, + ).colorScheme.error, + ), + ), + ], + ], + ), + ), + const Gap(12), + ], + Row( + spacing: 6, + children: [ + const Icon( + Symbols.account_balance_wallet, + size: 18, + ), + Expanded( + child: Text( + 'quotaCostInfo'.tr( + args: [ + _formatQuotaCost( + fileSize, + selectedPool, + ), + ], + ), + style: + Theme.of( + context, + ).textTheme.bodyMedium, + ).fontSize(13), + ), + ], + ).padding(horizontal: 4), + ], + ); + }, + ), + const Gap(4), + Row( + spacing: 6, + children: [ + const Icon(Symbols.info, size: 18), + Text( + 'attachmentPreview'.tr(), + style: Theme.of(context).textTheme.titleMedium, + ).fontSize(13), + ], + ).padding(horizontal: 4), + const Gap(8), + AttachmentPreview(item: attachment, isCompact: true), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton.icon( + onPressed: () => Navigator.pop(context), + icon: const Icon(Symbols.close), + label: Text('cancel').tr(), + ), + const Gap(8), + TextButton.icon( + onPressed: () => _confirmUpload(), + icon: const Icon(Symbols.upload), + label: Text('upload').tr(), + ), + ], + ), + ), + ], + ); + }, + ), + ); + } + + Future _getUploadConfig() async { + final attachment = widget.attachments[widget.index]; + final fileSize = await _getFileSize(attachment); + + if (fileSize == null) return null; + + // Get the selected pool to check constraints + final pools = await widget.ref.read(poolsProvider.future); + final selectedPool = pools.filterValid().firstWhere( + (p) => p.id == selectedPoolId, + ); + + // Check constraints + final maxFileSize = selectedPool.policyConfig?['max_file_size'] as int?; + final fileSizeExceeded = maxFileSize != null && fileSize > maxFileSize; + + final acceptTypes = selectedPool.policyConfig?['accept_types'] as List?; + final mimeType = + attachment.data.mimeType ?? + ComposeLogic.getMimeTypeFromFileType(attachment.type); + final typeAccepted = + acceptTypes == null || + acceptTypes.isEmpty || + acceptTypes.any((type) => mimeType.startsWith(type)); + + final hasConstraints = fileSizeExceeded || !typeAccepted; + + return AttachmentUploadConfig( + poolId: selectedPoolId!, + hasConstraints: hasConstraints, + ); + } + + Future _confirmUpload() async { + final config = await _getUploadConfig(); + if (config != null && mounted) { + Navigator.pop(context, config); + } + } + + Future _getFileSize(UniversalFile attachment) async { + if (attachment.data is XFile) { + try { + return await (attachment.data as XFile).length(); + } catch (e) { + return null; + } + } else if (attachment.data is SnCloudFile) { + return (attachment.data as SnCloudFile).size; + } else if (attachment.data is List) { + return (attachment.data as List).length; + } else if (attachment.data is Uint8List) { + return (attachment.data as Uint8List).length; + } + return null; + } + + String _formatNumber(int number) { + if (number >= 1000000) { + return '${(number / 1000000).toStringAsFixed(1)}M'; + } else if (number >= 1000) { + return '${(number / 1000).toStringAsFixed(1)}K'; + } else { + return number.toString(); + } + } + + String _formatFileSize(int bytes) { + if (bytes >= 1073741824) { + return '${(bytes / 1073741824).toStringAsFixed(1)} GB'; + } else if (bytes >= 1048576) { + return '${(bytes / 1048576).toStringAsFixed(1)} MB'; + } else if (bytes >= 1024) { + return '${(bytes / 1024).toStringAsFixed(1)} KB'; + } else { + return '$bytes bytes'; + } + } + + String _formatQuotaCost(int fileSize, SnFilePool pool) { + final costMultiplier = pool.billingConfig?['cost_multiplier'] ?? 1.0; + final quotaCost = ((fileSize / 1024 / 1024) * costMultiplier).round(); + return _formatNumber(quotaCost); + } +} diff --git a/lib/widgets/chat/chat_input.dart b/lib/widgets/chat/chat_input.dart index 6e61277d..eedf38e1 100644 --- a/lib/widgets/chat/chat_input.dart +++ b/lib/widgets/chat/chat_input.dart @@ -32,6 +32,7 @@ class ChatInput extends HookConsumerWidget { final Function(int) onDeleteAttachment; final Function(int, int) onMoveAttachment; final Function(List) onAttachmentsChanged; + final Map> attachmentProgress; const ChatInput({ super.key, @@ -48,6 +49,7 @@ class ChatInput extends HookConsumerWidget { required this.onDeleteAttachment, required this.onMoveAttachment, required this.onAttachmentsChanged, + required this.attachmentProgress, }); @override @@ -123,6 +125,7 @@ class ChatInput extends HookConsumerWidget { width: 280, child: AttachmentPreview( item: attachments[idx], + progress: attachmentProgress['chat-upload']?[idx], onRequestUpload: () => onUploadAttachment(idx), onDelete: () => onDeleteAttachment(idx), onUpdate: (value) {