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;
 | 
						|
      }
 | 
						|
    });
 | 
						|
  }
 | 
						|
}
 |