✨ Seprate uploading action in chat
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
import "dart:async";
|
import "dart:async";
|
||||||
import "dart:convert";
|
import "dart:convert";
|
||||||
|
import "dart:typed_data";
|
||||||
|
import "package:cross_file/cross_file.dart";
|
||||||
import "package:easy_localization/easy_localization.dart";
|
import "package:easy_localization/easy_localization.dart";
|
||||||
import "package:file_picker/file_picker.dart";
|
import "package:file_picker/file_picker.dart";
|
||||||
import "package:flutter/material.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/database/message.dart";
|
||||||
import "package:island/models/chat.dart";
|
import "package:island/models/chat.dart";
|
||||||
import "package:island/models/file.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/messages_notifier.dart";
|
||||||
import "package:island/pods/network.dart";
|
import "package:island/pods/network.dart";
|
||||||
import "package:island/pods/websocket.dart";
|
import "package:island/pods/websocket.dart";
|
||||||
|
import "package:island/services/file.dart";
|
||||||
import "package:island/screens/chat/chat.dart";
|
import "package:island/screens/chat/chat.dart";
|
||||||
import "package:island/services/responsive.dart";
|
import "package:island/services/responsive.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";
|
||||||
|
import "package:island/widgets/attachment_uploader.dart";
|
||||||
import "package:island/widgets/chat/call_overlay.dart";
|
import "package:island/widgets/chat/call_overlay.dart";
|
||||||
import "package:island/widgets/chat/message_item.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/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:island/widgets/response.dart";
|
||||||
import "package:material_symbols_icons/material_symbols_icons.dart";
|
import "package:material_symbols_icons/material_symbols_icons.dart";
|
||||||
import "package:styled_widget/styled_widget.dart";
|
import "package:styled_widget/styled_widget.dart";
|
||||||
@@ -464,6 +474,70 @@ class ChatRoomScreen extends HookConsumerWidget {
|
|||||||
|
|
||||||
const messageKeyPrefix = 'message-';
|
const messageKeyPrefix = 'message-';
|
||||||
|
|
||||||
|
Future<void> uploadAttachment(int index) async {
|
||||||
|
final attachment = attachments.value[index];
|
||||||
|
if (attachment.isOnCloud) return;
|
||||||
|
|
||||||
|
final config = await showModalBottomSheet<AttachmentUploadConfig>(
|
||||||
|
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<LocalChatMessage> messageList) =>
|
Widget chatMessageListWidget(List<LocalChatMessage> messageList) =>
|
||||||
SuperListView.builder(
|
SuperListView.builder(
|
||||||
listController: listController,
|
listController: listController,
|
||||||
@@ -779,9 +853,7 @@ class ChatRoomScreen extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
attachments: attachments.value,
|
attachments: attachments.value,
|
||||||
onUploadAttachment: (_) {
|
onUploadAttachment: uploadAttachment,
|
||||||
// not going to do anything, only upload when send the message
|
|
||||||
},
|
|
||||||
onDeleteAttachment: (index) async {
|
onDeleteAttachment: (index) async {
|
||||||
final attachment = attachments.value[index];
|
final attachment = attachments.value[index];
|
||||||
if (attachment.isOnCloud) {
|
if (attachment.isOnCloud) {
|
||||||
@@ -806,6 +878,7 @@ class ChatRoomScreen extends HookConsumerWidget {
|
|||||||
onAttachmentsChanged: (newAttachments) {
|
onAttachmentsChanged: (newAttachments) {
|
||||||
attachments.value = newAttachments;
|
attachments.value = newAttachments;
|
||||||
},
|
},
|
||||||
|
attachmentProgress: attachmentProgress.value,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -825,3 +898,344 @@ class ChatRoomScreen extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ChatAttachmentUploaderSheet extends StatefulWidget {
|
||||||
|
final WidgetRef ref;
|
||||||
|
final List<UniversalFile> attachments;
|
||||||
|
final int index;
|
||||||
|
|
||||||
|
const ChatAttachmentUploaderSheet({
|
||||||
|
super.key,
|
||||||
|
required this.ref,
|
||||||
|
required this.attachments,
|
||||||
|
required this.index,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ChatAttachmentUploaderSheet> createState() =>
|
||||||
|
_ChatAttachmentUploaderSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChatAttachmentUploaderSheetState
|
||||||
|
extends State<ChatAttachmentUploaderSheet> {
|
||||||
|
String? selectedPoolId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final attachment = widget.attachments[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.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<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@@ -32,6 +32,7 @@ class ChatInput extends HookConsumerWidget {
|
|||||||
final Function(int) onDeleteAttachment;
|
final Function(int) onDeleteAttachment;
|
||||||
final Function(int, int) onMoveAttachment;
|
final Function(int, int) onMoveAttachment;
|
||||||
final Function(List<UniversalFile>) onAttachmentsChanged;
|
final Function(List<UniversalFile>) onAttachmentsChanged;
|
||||||
|
final Map<String, Map<int, double>> attachmentProgress;
|
||||||
|
|
||||||
const ChatInput({
|
const ChatInput({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -48,6 +49,7 @@ class ChatInput extends HookConsumerWidget {
|
|||||||
required this.onDeleteAttachment,
|
required this.onDeleteAttachment,
|
||||||
required this.onMoveAttachment,
|
required this.onMoveAttachment,
|
||||||
required this.onAttachmentsChanged,
|
required this.onAttachmentsChanged,
|
||||||
|
required this.attachmentProgress,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -123,6 +125,7 @@ class ChatInput extends HookConsumerWidget {
|
|||||||
width: 280,
|
width: 280,
|
||||||
child: AttachmentPreview(
|
child: AttachmentPreview(
|
||||||
item: attachments[idx],
|
item: attachments[idx],
|
||||||
|
progress: attachmentProgress['chat-upload']?[idx],
|
||||||
onRequestUpload: () => onUploadAttachment(idx),
|
onRequestUpload: () => onUploadAttachment(idx),
|
||||||
onDelete: () => onDeleteAttachment(idx),
|
onDelete: () => onDeleteAttachment(idx),
|
||||||
onUpdate: (value) {
|
onUpdate: (value) {
|
||||||
|
Reference in New Issue
Block a user