From 612f1bf004a279b8dbdf09a7388f18108358f822 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Wed, 24 Sep 2025 16:45:24 +0800 Subject: [PATCH] :sparkles: File uploader --- assets/i18n/en-US.json | 12 +- .../{room_providers.dart => chat_rooms.dart} | 0 .../{pool_provider.dart => file_pool.dart} | 0 lib/pods/messages_notifier.dart | 2 +- lib/screens/files/file_list.dart | 2 +- lib/screens/posts/compose.dart | 46 ++- lib/screens/posts/compose_article.dart | 25 +- lib/screens/settings.dart | 2 +- lib/widgets/attachment_uploader.dart | 365 ++++++++++++++++++ lib/widgets/content/attachment_preview.dart | 3 +- lib/widgets/post/compose_shared.dart | 4 +- 11 files changed, 447 insertions(+), 14 deletions(-) rename lib/pods/{room_providers.dart => chat_rooms.dart} (100%) rename lib/pods/{pool_provider.dart => file_pool.dart} (100%) create mode 100644 lib/widgets/attachment_uploader.dart diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index 24adfe65..2f683cba 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -908,6 +908,15 @@ "attachmentOnDevice": "On-device", "attachmentOnCloud": "On-cloud", "attachments": "Attachments", + "uploadAttachment": "Upload Attachment", + "attachmentPreview": "Attachment Preview", + "selectPool": "Select Pool", + "choosePool": "Choose a pool", + "errorLoadingPools": "Error loading pools", + "quotaCostInfo": "This upload will cost {} quota points", + "uploadConstraints": "Upload Constraints", + "fileSizeExceeded": "File size exceeds the maximum limit of {}", + "fileTypeNotAccepted": "File type is not accepted by this pool", "publisherCollabInvitation": "Collabration invitations", "publisherCollabInvitationCount": { "zero": "No invitation", @@ -1052,5 +1061,6 @@ "confirmDeleteRecycledFiles": "Are you sure you want to delete all recycled files?", "deleteRecycledFiles": "Delete Recycled Files", "recycledFilesDeleted": "Recycled files deleted successfully", - "failedToDeleteRecycledFiles": "Failed to delete recycled files" + "failedToDeleteRecycledFiles": "Failed to delete recycled files", + "upload": "Upload" } diff --git a/lib/pods/room_providers.dart b/lib/pods/chat_rooms.dart similarity index 100% rename from lib/pods/room_providers.dart rename to lib/pods/chat_rooms.dart diff --git a/lib/pods/pool_provider.dart b/lib/pods/file_pool.dart similarity index 100% rename from lib/pods/pool_provider.dart rename to lib/pods/file_pool.dart diff --git a/lib/pods/messages_notifier.dart b/lib/pods/messages_notifier.dart index 81e335d6..be0d9ac5 100644 --- a/lib/pods/messages_notifier.dart +++ b/lib/pods/messages_notifier.dart @@ -16,7 +16,7 @@ import "package:island/widgets/alert.dart"; import "package:riverpod_annotation/riverpod_annotation.dart"; import "package:uuid/uuid.dart"; import "package:island/screens/chat/chat.dart"; -import "package:island/pods/room_providers.dart"; +import "package:island/pods/chat_rooms.dart"; part 'messages_notifier.g.dart'; diff --git a/lib/screens/files/file_list.dart b/lib/screens/files/file_list.dart index 35fd2416..97d9059b 100644 --- a/lib/screens/files/file_list.dart +++ b/lib/screens/files/file_list.dart @@ -6,7 +6,7 @@ import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/models/file.dart'; import 'package:island/pods/network.dart'; -import 'package:island/pods/pool_provider.dart'; +import 'package:island/pods/file_pool.dart'; import 'package:island/utils/format.dart'; import 'package:island/widgets/alert.dart'; import 'package:island/widgets/app_scaffold.dart'; diff --git a/lib/screens/posts/compose.dart b/lib/screens/posts/compose.dart index 0e45d0b8..85ac7218 100644 --- a/lib/screens/posts/compose.dart +++ b/lib/screens/posts/compose.dart @@ -10,6 +10,7 @@ import 'package:island/screens/creators/publishers.dart'; import 'package:island/screens/posts/compose_article.dart'; import 'package:island/services/responsive.dart'; import 'package:island/widgets/app_scaffold.dart'; +import 'package:island/widgets/attachment_uploader.dart'; import 'package:island/widgets/content/attachment_preview.dart'; import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/post/compose_shared.dart'; @@ -225,8 +226,26 @@ class PostComposeScreen extends HookConsumerWidget { return AttachmentPreview( item: state.attachments.value[idx], progress: progressMap[idx], - onRequestUpload: - () => ComposeLogic.uploadAttachment(ref, state, idx), + onRequestUpload: () async { + final config = await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: + (context) => AttachmentUploaderSheet( + ref: ref, + state: state, + index: idx, + ), + ); + if (config != null) { + await ComposeLogic.uploadAttachment( + ref, + state, + idx, + poolId: config.poolId, + ); + } + }, onDelete: () => ComposeLogic.deleteAttachment(ref, state, idx), onUpdate: (value) => ComposeLogic.updateAttachment(state, value, idx), @@ -253,8 +272,27 @@ class PostComposeScreen extends HookConsumerWidget { return AttachmentPreview( item: state.attachments.value[idx], progress: progressMap[idx], - onRequestUpload: - () => ComposeLogic.uploadAttachment(ref, state, idx), + onRequestUpload: () async { + final config = + await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: + (context) => AttachmentUploaderSheet( + ref: ref, + state: state, + index: idx, + ), + ); + if (config != null) { + await ComposeLogic.uploadAttachment( + ref, + state, + idx, + poolId: config.poolId, + ); + } + }, onDelete: () => ComposeLogic.deleteAttachment(ref, state, idx), onUpdate: diff --git a/lib/screens/posts/compose_article.dart b/lib/screens/posts/compose_article.dart index 7384c1a8..6864d86f 100644 --- a/lib/screens/posts/compose_article.dart +++ b/lib/screens/posts/compose_article.dart @@ -11,6 +11,7 @@ import 'package:island/models/post.dart'; import 'package:island/screens/creators/publishers.dart'; import 'package:island/services/responsive.dart'; import 'package:island/widgets/app_scaffold.dart'; +import 'package:island/widgets/attachment_uploader.dart'; import 'package:island/screens/posts/post_detail.dart'; import 'package:island/widgets/content/attachment_preview.dart'; import 'package:island/widgets/content/cloud_files.dart'; @@ -345,12 +346,30 @@ class ArticleComposeScreen extends HookConsumerWidget { isCompact: true, item: attachments[idx], progress: progressMap[idx], - onRequestUpload: - () => ComposeLogic.uploadAttachment( + onRequestUpload: () async { + final config = + await showModalBottomSheet< + AttachmentUploadConfig + >( + context: context, + isScrollControlled: true, + builder: + (context) => + AttachmentUploaderSheet( + ref: ref, + state: state, + index: idx, + ), + ); + if (config != null) { + await ComposeLogic.uploadAttachment( ref, state, idx, - ), + poolId: config.poolId, + ); + } + }, onUpdate: (value) => ComposeLogic.updateAttachment( diff --git a/lib/screens/settings.dart b/lib/screens/settings.dart index 287e655e..bcf37fa6 100644 --- a/lib/screens/settings.dart +++ b/lib/screens/settings.dart @@ -21,7 +21,7 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:path_provider/path_provider.dart'; import 'package:styled_widget/styled_widget.dart'; import 'package:island/pods/config.dart'; -import 'package:island/pods/pool_provider.dart'; +import 'package:island/pods/file_pool.dart'; import 'package:island/models/file_pool.dart'; class SettingsScreen extends HookConsumerWidget { diff --git a/lib/widgets/attachment_uploader.dart b/lib/widgets/attachment_uploader.dart new file mode 100644 index 00000000..0b91c946 --- /dev/null +++ b/lib/widgets/attachment_uploader.dart @@ -0,0 +1,365 @@ +import 'dart:typed_data'; + +import 'package:cross_file/cross_file.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/models/file.dart'; +import 'package:island/models/file_pool.dart'; +import 'package:island/pods/file_pool.dart'; +import 'package:island/widgets/content/attachment_preview.dart'; +import 'package:island/widgets/content/sheet.dart'; +import 'package:island/widgets/post/compose_shared.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:gap/gap.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:styled_widget/styled_widget.dart'; + +class AttachmentUploadConfig { + final String poolId; + final bool hasConstraints; + + const AttachmentUploadConfig({ + required this.poolId, + required this.hasConstraints, + }); +} + +class AttachmentUploaderSheet extends StatefulWidget { + final WidgetRef ref; + final ComposeState state; + final int index; + + const AttachmentUploaderSheet({ + super.key, + required this.ref, + required this.state, + required this.index, + }); + + @override + State createState() => + _AttachmentUploaderSheetState(); +} + +class _AttachmentUploaderSheetState extends State { + String? selectedPoolId; + + @override + Widget build(BuildContext context) { + final attachment = widget.state.attachments.value[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.state.attachments.value[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/content/attachment_preview.dart b/lib/widgets/content/attachment_preview.dart index 9f09c950..f7d174ac 100644 --- a/lib/widgets/content/attachment_preview.dart +++ b/lib/widgets/content/attachment_preview.dart @@ -470,7 +470,8 @@ class AttachmentPreview extends HookConsumerWidget { if (onRequestUpload != null) InkWell( borderRadius: BorderRadius.circular(8), - onTap: () => onRequestUpload?.call(), + onTap: + item.isOnCloud ? null : () => onRequestUpload?.call(), child: ClipRRect( borderRadius: BorderRadius.circular(8), child: Container( diff --git a/lib/widgets/post/compose_shared.dart b/lib/widgets/post/compose_shared.dart index 15de2b4f..4201dbec 100644 --- a/lib/widgets/post/compose_shared.dart +++ b/lib/widgets/post/compose_shared.dart @@ -20,7 +20,7 @@ import 'package:island/widgets/alert.dart'; import 'package:island/widgets/post/compose_link_attachments.dart'; import 'package:island/widgets/post/compose_poll.dart'; import 'package:island/widgets/post/compose_recorder.dart'; -import 'package:island/pods/pool_provider.dart'; +import 'package:island/pods/file_pool.dart'; import 'package:pasteboard/pasteboard.dart'; import 'package:textfield_tags/textfield_tags.dart'; import 'dart:async'; @@ -672,7 +672,7 @@ class ComposeLogic { try { state.submitting.value = true; - // Upload any local attachments first + // pload any local attachments first await Future.wait( state.attachments.value .asMap()