382 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			382 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
| 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 List<UniversalFile>? attachments;
 | |
|   final int index;
 | |
| 
 | |
|   const AttachmentUploaderSheet({
 | |
|     super.key,
 | |
|     required this.ref,
 | |
|     this.state,
 | |
|     this.attachments,
 | |
|     required this.index,
 | |
|   }) : assert(
 | |
|          state != null || attachments != null,
 | |
|          'Either state or attachments must be provided',
 | |
|        );
 | |
| 
 | |
|   @override
 | |
|   State<AttachmentUploaderSheet> createState() =>
 | |
|       _AttachmentUploaderSheetState();
 | |
| }
 | |
| 
 | |
| class _AttachmentUploaderSheetState extends State<AttachmentUploaderSheet> {
 | |
|   String? selectedPoolId;
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context) {
 | |
|     final attachment =
 | |
|         widget.attachments?[widget.index] ??
 | |
|         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!;
 | |
|           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?)
 | |
|                                   ?.cast<String>();
 | |
|                           final mimeType =
 | |
|                               attachment.data.mimeType ??
 | |
|                               ComposeLogic.getMimeTypeFromFileType(
 | |
|                                 attachment.type,
 | |
|                               );
 | |
|                           final typeAccepted = _isMimeTypeAccepted(
 | |
|                             mimeType,
 | |
|                             acceptTypes,
 | |
|                           );
 | |
| 
 | |
|                           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] ??
 | |
|         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.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?)?.cast<String>();
 | |
|     final mimeType =
 | |
|         attachment.data.mimeType ??
 | |
|         ComposeLogic.getMimeTypeFromFileType(attachment.type);
 | |
|     final typeAccepted = _isMimeTypeAccepted(mimeType, acceptTypes);
 | |
| 
 | |
|     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);
 | |
|   }
 | |
| 
 | |
|   bool _isMimeTypeAccepted(String mimeType, List<String>? acceptTypes) {
 | |
|     if (acceptTypes == null || acceptTypes.isEmpty) return true;
 | |
|     return acceptTypes.any((type) {
 | |
|       if (type.endsWith('/*')) {
 | |
|         final mainType = type.substring(0, type.length - 2);
 | |
|         return mimeType.startsWith('$mainType/');
 | |
|       } else {
 | |
|         return mimeType == type;
 | |
|       }
 | |
|     });
 | |
|   }
 | |
| }
 |