Surface/lib/widgets/content/cloud_file_picker.dart
2025-05-11 22:05:54 +08:00

313 lines
11 KiB
Dart

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<List<UniversalFile>>([]);
final uploadPosition = useState<int?>(null);
final uploadProgress = useState<double?>(null);
final uploadOverallProgress = useMemoized<double?>(() {
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<void> 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<SnCloudFile> 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),
),
),
],
),
);
}
}