import 'package:easy_localization/easy_localization.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:image_picker/image_picker.dart'; import 'package:island/models/file.dart'; import 'package:island/pods/config.dart'; import 'package:island/pods/network.dart'; import 'package:island/screens/posts/compose.dart'; import 'package:island/services/file.dart'; import 'package:island/widgets/alert.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:styled_widget/styled_widget.dart'; class CloudFilePicker extends HookConsumerWidget { final bool allowMultiple; const CloudFilePicker({super.key, this.allowMultiple = false}); @override Widget build(BuildContext context, WidgetRef ref) { final files = useState>([]); final uploadPosition = useState(null); final uploadProgress = useState(null); final uploadOverallProgress = useMemoized(() { if (uploadPosition.value == null || uploadProgress.value == null) { return null; } // Calculate completed files (100% each) + current file progress final completedProgress = uploadPosition.value! * 100.0; final currentProgress = uploadProgress.value!; // Calculate overall progress as percentage return (completedProgress + currentProgress) / (files.value.length * 100.0); }, [uploadPosition.value, uploadProgress.value, files.value.length]); Future startUpload() async { if (files.value.isEmpty) return; final baseUrl = ref.read(serverUrlProvider); final atk = await getFreshAtk( ref.watch(tokenPairProvider), baseUrl, onRefreshed: (atk, rtk) { setTokenPair(ref.watch(sharedPreferencesProvider), atk, rtk); ref.invalidate(tokenPairProvider); }, ); if (atk == null) throw Exception("Unauthorized"); List result = List.empty(growable: true); uploadProgress.value = 0; uploadPosition.value = 0; try { for (var idx = 0; idx < files.value.length; idx++) { uploadPosition.value = idx; final file = files.value[idx]; final cloudFile = await putMediaToCloud( fileData: file.data, atk: atk, baseUrl: baseUrl, filename: file.data.name ?? 'Post media', mimetype: file.data.mimeType ?? switch (file.type) { UniversalFileType.image => 'image/unknown', UniversalFileType.video => 'video/unknown', UniversalFileType.audio => 'audio/unknown', UniversalFileType.file => 'application/octet-stream', }, onProgress: (progress, _) { uploadProgress.value = progress; }, ).future; if (cloudFile == null) { throw ArgumentError('Failed to upload the file...'); } result.add(cloudFile); } if (context.mounted) Navigator.pop(context, result); } catch (err) { showErrorAlert(err); } } void pickFile() async { showLoadingModal(context); final result = await FilePickerIO().pickFiles( allowMultiple: allowMultiple, ); if (result == null) { if (context.mounted) hideLoadingModal(context); return; } final newFiles = result.files .map((e) => UniversalFile(data: e, type: UniversalFileType.file)) .toList(); if (!allowMultiple) { files.value = newFiles; if (context.mounted) { hideLoadingModal(context); startUpload(); } return; } files.value = [...files.value, ...newFiles]; if (context.mounted) hideLoadingModal(context); } void pickImage() async { showLoadingModal(context); final result = allowMultiple ? await ref.read(imagePickerProvider).pickMultiImage() : [ await ref .read(imagePickerProvider) .pickImage(source: ImageSource.gallery), ]; if (result.isEmpty) { if (context.mounted) hideLoadingModal(context); return; } final newFiles = result .map((e) => UniversalFile(data: e, type: UniversalFileType.image)) .toList(); if (!allowMultiple) { files.value = newFiles; if (context.mounted) { hideLoadingModal(context); startUpload(); } return; } files.value = [...files.value, ...newFiles]; if (context.mounted) hideLoadingModal(context); } void pickVideo() async { showLoadingModal(context); final result = await ref .read(imagePickerProvider) .pickVideo(source: ImageSource.gallery); if (result == null) { if (context.mounted) hideLoadingModal(context); return; } final newFile = UniversalFile( data: result, type: UniversalFileType.video, ); if (!allowMultiple) { files.value = [newFile]; if (context.mounted) { hideLoadingModal(context); startUpload(); } return; } files.value = [...files.value, newFile]; if (context.mounted) hideLoadingModal(context); } return Container( constraints: BoxConstraints( maxHeight: MediaQuery.of(context).size.height * 0.5, ), child: Column( mainAxisSize: MainAxisSize.min, children: [ Padding( padding: EdgeInsets.only(top: 16, left: 20, right: 16, bottom: 12), child: Row( children: [ Text( 'pickFile'.tr(), style: Theme.of(context).textTheme.headlineSmall?.copyWith( fontWeight: FontWeight.w600, letterSpacing: -0.5, ), ), const Spacer(), IconButton( icon: const Icon(Symbols.close), onPressed: () => Navigator.pop(context), style: IconButton.styleFrom(minimumSize: const Size(36, 36)), ), ], ), ), const Divider(height: 1), Expanded( child: SingleChildScrollView( child: Column( spacing: 16, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (uploadOverallProgress != null) Column( spacing: 6, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text('uploadingProgress') .tr( args: [ ((uploadPosition.value ?? 0) + 1).toString(), files.value.length.toString(), ], ) .opacity(0.85), LinearProgressIndicator( value: uploadOverallProgress, color: Theme.of(context).colorScheme.primary, backgroundColor: Theme.of(context).colorScheme.surfaceVariant, ), ], ), if (files.value.isNotEmpty) Align( alignment: Alignment.centerLeft, child: ElevatedButton.icon( onPressed: startUpload, icon: const Icon(Symbols.play_arrow), label: Text('uploadAll'.tr()), ), ), if (files.value.isNotEmpty) SizedBox( height: 280, child: ListView.separated( scrollDirection: Axis.horizontal, itemCount: files.value.length, itemBuilder: (context, idx) { return AttachmentPreview( onDelete: uploadOverallProgress != null ? null : () { files.value = [ ...files.value.where( (e) => e != files.value[idx], ), ]; }, item: files.value[idx], progress: null, ); }, separatorBuilder: (_, __) => const Gap(8), ), ), Card( color: Theme.of(context).colorScheme.surfaceContainer, margin: EdgeInsets.zero, child: Column( children: [ ListTile( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), leading: const Icon(Symbols.photo), title: Text('addPhoto'.tr()), onTap: () => pickImage(), ), ListTile( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), leading: const Icon(Symbols.video_call), title: Text('addVideo'.tr()), onTap: () => pickVideo(), ), ListTile( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), leading: const Icon(Symbols.draft), title: Text('addFile'.tr()), onTap: () => pickFile(), ), ], ), ), ], ).padding(all: 24), ), ), ], ), ); } }