File uploader

This commit is contained in:
2025-09-24 16:45:24 +08:00
parent fd80b713ad
commit 612f1bf004
11 changed files with 447 additions and 14 deletions

View File

@@ -908,6 +908,15 @@
"attachmentOnDevice": "On-device", "attachmentOnDevice": "On-device",
"attachmentOnCloud": "On-cloud", "attachmentOnCloud": "On-cloud",
"attachments": "Attachments", "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", "publisherCollabInvitation": "Collabration invitations",
"publisherCollabInvitationCount": { "publisherCollabInvitationCount": {
"zero": "No invitation", "zero": "No invitation",
@@ -1052,5 +1061,6 @@
"confirmDeleteRecycledFiles": "Are you sure you want to delete all recycled files?", "confirmDeleteRecycledFiles": "Are you sure you want to delete all recycled files?",
"deleteRecycledFiles": "Delete Recycled Files", "deleteRecycledFiles": "Delete Recycled Files",
"recycledFilesDeleted": "Recycled files deleted successfully", "recycledFilesDeleted": "Recycled files deleted successfully",
"failedToDeleteRecycledFiles": "Failed to delete recycled files" "failedToDeleteRecycledFiles": "Failed to delete recycled files",
"upload": "Upload"
} }

View File

@@ -16,7 +16,7 @@ import "package:island/widgets/alert.dart";
import "package:riverpod_annotation/riverpod_annotation.dart"; import "package:riverpod_annotation/riverpod_annotation.dart";
import "package:uuid/uuid.dart"; import "package:uuid/uuid.dart";
import "package:island/screens/chat/chat.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'; part 'messages_notifier.g.dart';

View File

@@ -6,7 +6,7 @@ import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart'; import 'package:island/models/file.dart';
import 'package:island/pods/network.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/utils/format.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';

View File

@@ -10,6 +10,7 @@ import 'package:island/screens/creators/publishers.dart';
import 'package:island/screens/posts/compose_article.dart'; import 'package:island/screens/posts/compose_article.dart';
import 'package:island/services/responsive.dart'; import 'package:island/services/responsive.dart';
import 'package:island/widgets/app_scaffold.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/attachment_preview.dart';
import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/post/compose_shared.dart'; import 'package:island/widgets/post/compose_shared.dart';
@@ -225,8 +226,26 @@ class PostComposeScreen extends HookConsumerWidget {
return AttachmentPreview( return AttachmentPreview(
item: state.attachments.value[idx], item: state.attachments.value[idx],
progress: progressMap[idx], progress: progressMap[idx],
onRequestUpload: onRequestUpload: () async {
() => ComposeLogic.uploadAttachment(ref, state, idx), 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,
);
}
},
onDelete: () => ComposeLogic.deleteAttachment(ref, state, idx), onDelete: () => ComposeLogic.deleteAttachment(ref, state, idx),
onUpdate: onUpdate:
(value) => ComposeLogic.updateAttachment(state, value, idx), (value) => ComposeLogic.updateAttachment(state, value, idx),
@@ -253,8 +272,27 @@ class PostComposeScreen extends HookConsumerWidget {
return AttachmentPreview( return AttachmentPreview(
item: state.attachments.value[idx], item: state.attachments.value[idx],
progress: progressMap[idx], progress: progressMap[idx],
onRequestUpload: onRequestUpload: () async {
() => ComposeLogic.uploadAttachment(ref, state, idx), 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,
);
}
},
onDelete: onDelete:
() => ComposeLogic.deleteAttachment(ref, state, idx), () => ComposeLogic.deleteAttachment(ref, state, idx),
onUpdate: onUpdate:

View File

@@ -11,6 +11,7 @@ import 'package:island/models/post.dart';
import 'package:island/screens/creators/publishers.dart'; import 'package:island/screens/creators/publishers.dart';
import 'package:island/services/responsive.dart'; import 'package:island/services/responsive.dart';
import 'package:island/widgets/app_scaffold.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/screens/posts/post_detail.dart';
import 'package:island/widgets/content/attachment_preview.dart'; import 'package:island/widgets/content/attachment_preview.dart';
import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/content/cloud_files.dart';
@@ -345,12 +346,30 @@ class ArticleComposeScreen extends HookConsumerWidget {
isCompact: true, isCompact: true,
item: attachments[idx], item: attachments[idx],
progress: progressMap[idx], progress: progressMap[idx],
onRequestUpload: onRequestUpload: () async {
() => ComposeLogic.uploadAttachment( 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, ref,
state, state,
idx, idx,
), poolId: config.poolId,
);
}
},
onUpdate: onUpdate:
(value) => (value) =>
ComposeLogic.updateAttachment( ComposeLogic.updateAttachment(

View File

@@ -21,7 +21,7 @@ import 'package:material_symbols_icons/symbols.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:island/pods/config.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'; import 'package:island/models/file_pool.dart';
class SettingsScreen extends HookConsumerWidget { class SettingsScreen extends HookConsumerWidget {

View File

@@ -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<AttachmentUploaderSheet> createState() =>
_AttachmentUploaderSheetState();
}
class _AttachmentUploaderSheetState extends State<AttachmentUploaderSheet> {
String? selectedPoolId;
@override
Widget build(BuildContext context) {
final attachment = widget.state.attachments.value[widget.index];
return SheetScaffold(
titleText: 'uploadAttachment'.tr(),
child: FutureBuilder<List<SnFilePool>>(
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<String>(
value: selectedPoolId,
items:
pools.map((pool) {
return DropdownMenuItem<String>(
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<int?>(
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<AttachmentUploadConfig?> _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<void> _confirmUpload() async {
final config = await _getUploadConfig();
if (config != null && mounted) {
Navigator.pop(context, config);
}
}
Future<int?> _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<int>) {
return (attachment.data as List<int>).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);
}
}

View File

@@ -470,7 +470,8 @@ class AttachmentPreview extends HookConsumerWidget {
if (onRequestUpload != null) if (onRequestUpload != null)
InkWell( InkWell(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
onTap: () => onRequestUpload?.call(), onTap:
item.isOnCloud ? null : () => onRequestUpload?.call(),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: Container( child: Container(

View File

@@ -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_link_attachments.dart';
import 'package:island/widgets/post/compose_poll.dart'; import 'package:island/widgets/post/compose_poll.dart';
import 'package:island/widgets/post/compose_recorder.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:pasteboard/pasteboard.dart';
import 'package:textfield_tags/textfield_tags.dart'; import 'package:textfield_tags/textfield_tags.dart';
import 'dart:async'; import 'dart:async';
@@ -672,7 +672,7 @@ class ComposeLogic {
try { try {
state.submitting.value = true; state.submitting.value = true;
// Upload any local attachments first // pload any local attachments first
await Future.wait( await Future.wait(
state.attachments.value state.attachments.value
.asMap() .asMap()