605 lines
		
	
	
		
			23 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			605 lines
		
	
	
		
			23 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
| import 'dart:convert';
 | |
| import 'dart:io';
 | |
| 
 | |
| import 'package:cross_file/cross_file.dart';
 | |
| import 'package:easy_localization/easy_localization.dart';
 | |
| import 'package:flutter/foundation.dart';
 | |
| import 'package:flutter/material.dart';
 | |
| import 'package:gap/gap.dart';
 | |
| import 'package:hooks_riverpod/hooks_riverpod.dart';
 | |
| import 'package:island/models/file.dart';
 | |
| import 'package:island/pods/network.dart';
 | |
| import 'package:island/services/file.dart';
 | |
| import 'package:island/services/file_uploader.dart';
 | |
| import 'package:island/utils/format.dart';
 | |
| import 'package:island/widgets/alert.dart';
 | |
| import 'package:island/widgets/content/cloud_files.dart';
 | |
| import 'package:island/widgets/content/sheet.dart';
 | |
| import 'package:material_symbols_icons/symbols.dart';
 | |
| import 'package:styled_widget/styled_widget.dart';
 | |
| import 'package:super_context_menu/super_context_menu.dart';
 | |
| 
 | |
| import 'sensitive.dart';
 | |
| 
 | |
| class SensitiveMarksSelector extends StatefulWidget {
 | |
|   final List<int> initial;
 | |
|   final ValueChanged<List<int>>? onChanged;
 | |
| 
 | |
|   const SensitiveMarksSelector({
 | |
|     super.key,
 | |
|     required this.initial,
 | |
|     this.onChanged,
 | |
|   });
 | |
| 
 | |
|   @override
 | |
|   State<SensitiveMarksSelector> createState() => SensitiveMarksSelectorState();
 | |
| }
 | |
| 
 | |
| class SensitiveMarksSelectorState extends State<SensitiveMarksSelector> {
 | |
|   late List<int> _selected;
 | |
| 
 | |
|   List<int> get current => _selected;
 | |
| 
 | |
|   @override
 | |
|   void initState() {
 | |
|     super.initState();
 | |
|     _selected = [...widget.initial];
 | |
|   }
 | |
| 
 | |
|   void _toggle(int value) {
 | |
|     setState(() {
 | |
|       if (_selected.contains(value)) {
 | |
|         _selected.remove(value);
 | |
|       } else {
 | |
|         _selected.add(value);
 | |
|       }
 | |
|     });
 | |
|     widget.onChanged?.call([..._selected]);
 | |
|   }
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context) {
 | |
|     // Build a list of all categories in fixed order as int list indices
 | |
|     final categories = kSensitiveCategoriesOrdered;
 | |
| 
 | |
|     return Column(
 | |
|       crossAxisAlignment: CrossAxisAlignment.stretch,
 | |
|       children: [
 | |
|         Wrap(
 | |
|           spacing: 8,
 | |
|           children: [
 | |
|             for (var i = 0; i < categories.length; i++)
 | |
|               FilterChip(
 | |
|                 label: Text(categories[i].i18nKey.tr()),
 | |
|                 avatar: Text(categories[i].symbol),
 | |
|                 selected: _selected.contains(i),
 | |
|                 onSelected: (_) => _toggle(i),
 | |
|               ),
 | |
|           ],
 | |
|         ),
 | |
|       ],
 | |
|     );
 | |
|   }
 | |
| }
 | |
| 
 | |
| class AttachmentPreview extends HookConsumerWidget {
 | |
|   final UniversalFile item;
 | |
|   final double? progress;
 | |
|   final Function(int)? onMove;
 | |
|   final Function? onDelete;
 | |
|   final Function? onInsert;
 | |
|   final Function(UniversalFile)? onUpdate;
 | |
|   final Function? onRequestUpload;
 | |
|   final bool isCompact;
 | |
| 
 | |
|   const AttachmentPreview({
 | |
|     super.key,
 | |
|     required this.item,
 | |
|     this.progress,
 | |
|     this.onRequestUpload,
 | |
|     this.onMove,
 | |
|     this.onDelete,
 | |
|     this.onUpdate,
 | |
|     this.onInsert,
 | |
|     this.isCompact = false,
 | |
|   });
 | |
| 
 | |
|   // GlobalKey for selector
 | |
|   static final GlobalKey<SensitiveMarksSelectorState> _sensitiveSelectorKey =
 | |
|       GlobalKey<SensitiveMarksSelectorState>();
 | |
| 
 | |
|   String _getDisplayName() {
 | |
|     return item.displayName ??
 | |
|         (item.data is XFile
 | |
|             ? (item.data as XFile).name
 | |
|             : item.isOnCloud
 | |
|             ? item.data.name
 | |
|             : '');
 | |
|   }
 | |
| 
 | |
|   Future<void> _showRenameSheet(BuildContext context, WidgetRef ref) async {
 | |
|     final nameController = TextEditingController(text: _getDisplayName());
 | |
|     String? errorMessage;
 | |
| 
 | |
|     await showModalBottomSheet(
 | |
|       context: context,
 | |
|       isScrollControlled: true,
 | |
|       useRootNavigator: true,
 | |
|       builder:
 | |
|           (context) => SheetScaffold(
 | |
|             heightFactor: 0.6,
 | |
|             titleText: 'rename'.tr(),
 | |
|             child: Column(
 | |
|               mainAxisSize: MainAxisSize.min,
 | |
|               crossAxisAlignment: CrossAxisAlignment.stretch,
 | |
|               children: [
 | |
|                 Padding(
 | |
|                   padding: const EdgeInsets.symmetric(
 | |
|                     horizontal: 24,
 | |
|                     vertical: 24,
 | |
|                   ),
 | |
|                   child: TextField(
 | |
|                     controller: nameController,
 | |
|                     decoration: InputDecoration(
 | |
|                       labelText: 'fileName'.tr(),
 | |
|                       border: const OutlineInputBorder(),
 | |
|                       errorText: errorMessage,
 | |
|                     ),
 | |
|                   ),
 | |
|                 ),
 | |
|                 Row(
 | |
|                   mainAxisAlignment: MainAxisAlignment.end,
 | |
|                   children: [
 | |
|                     TextButton(
 | |
|                       onPressed: () => Navigator.pop(context),
 | |
|                       child: Text('cancel'.tr()),
 | |
|                     ),
 | |
|                     const Gap(8),
 | |
|                     TextButton(
 | |
|                       onPressed: () async {
 | |
|                         final newName = nameController.text.trim();
 | |
|                         if (newName.isEmpty) {
 | |
|                           errorMessage = 'fieldCannotBeEmpty'.tr();
 | |
|                           return;
 | |
|                         }
 | |
| 
 | |
|                         if (item.isOnCloud) {
 | |
|                           try {
 | |
|                             showLoadingModal(context);
 | |
|                             final apiClient = ref.watch(apiClientProvider);
 | |
|                             await apiClient.patch(
 | |
|                               '/drive/files/${item.data.id}/name',
 | |
|                               data: jsonEncode(newName),
 | |
|                             );
 | |
|                             final newData = item.data;
 | |
|                             newData.name = newName;
 | |
|                             onUpdate?.call(
 | |
|                               item.copyWith(
 | |
|                                 data: newData,
 | |
|                                 displayName: newName,
 | |
|                               ),
 | |
|                             );
 | |
|                             if (context.mounted) Navigator.pop(context);
 | |
|                           } catch (err) {
 | |
|                             showErrorAlert(err);
 | |
|                           } finally {
 | |
|                             if (context.mounted) hideLoadingModal(context);
 | |
|                           }
 | |
|                         } else {
 | |
|                           // Local file rename
 | |
|                           onUpdate?.call(item.copyWith(displayName: newName));
 | |
|                           if (context.mounted) Navigator.pop(context);
 | |
|                         }
 | |
|                       },
 | |
|                       child: Text('rename'.tr()),
 | |
|                     ),
 | |
|                   ],
 | |
|                 ).padding(horizontal: 16, vertical: 8),
 | |
|               ],
 | |
|             ),
 | |
|           ),
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   Future<void> _showSensitiveDialog(BuildContext context, WidgetRef ref) async {
 | |
|     await showModalBottomSheet(
 | |
|       context: context,
 | |
|       isScrollControlled: true,
 | |
|       builder:
 | |
|           (context) => SheetScaffold(
 | |
|             heightFactor: 0.6,
 | |
|             titleText: 'markAsSensitive'.tr(),
 | |
|             child: Column(
 | |
|               mainAxisSize: MainAxisSize.min,
 | |
|               crossAxisAlignment: CrossAxisAlignment.stretch,
 | |
|               children: [
 | |
|                 Padding(
 | |
|                   padding: const EdgeInsets.symmetric(
 | |
|                     horizontal: 24,
 | |
|                     vertical: 24,
 | |
|                   ),
 | |
|                   child: Column(
 | |
|                     children: [
 | |
|                       // Sensitive categories checklist
 | |
|                       SensitiveMarksSelector(
 | |
|                         key: _sensitiveSelectorKey,
 | |
|                         initial:
 | |
|                             (item.data.sensitiveMarks ?? [])
 | |
|                                 .map((e) => e as int)
 | |
|                                 .cast<int>()
 | |
|                                 .toList(),
 | |
|                         onChanged: (marks) {
 | |
|                           // Update local data immediately (optimistic)
 | |
|                           final newData = item.data;
 | |
|                           newData.sensitiveMarks = marks;
 | |
|                           final updatedFile = item.copyWith(data: newData);
 | |
|                           onUpdate?.call(item.copyWith(data: updatedFile));
 | |
|                         },
 | |
|                       ),
 | |
|                     ],
 | |
|                   ),
 | |
|                 ),
 | |
|                 Row(
 | |
|                   mainAxisAlignment: MainAxisAlignment.end,
 | |
|                   children: [
 | |
|                     TextButton(
 | |
|                       onPressed: () => Navigator.pop(context),
 | |
|                       child: Text('cancel'.tr()),
 | |
|                     ),
 | |
|                     const Gap(8),
 | |
|                     TextButton(
 | |
|                       onPressed: () async {
 | |
|                         try {
 | |
|                           showLoadingModal(context);
 | |
|                           final apiClient = ref.watch(apiClientProvider);
 | |
|                           // Use the current selections from stateful selector via GlobalKey
 | |
|                           final selectorState =
 | |
|                               _sensitiveSelectorKey.currentState;
 | |
|                           final marks = selectorState?.current ?? <int>[];
 | |
|                           await apiClient.put(
 | |
|                             '/drive/files/${item.data.id}/marks',
 | |
|                             data: jsonEncode({'sensitive_marks': marks}),
 | |
|                           );
 | |
|                           final newData = item.data as SnCloudFile;
 | |
|                           final updatedFile = item.copyWith(
 | |
|                             data: newData.copyWith(sensitiveMarks: marks),
 | |
|                           );
 | |
|                           onUpdate?.call(updatedFile);
 | |
|                           if (context.mounted) Navigator.pop(context);
 | |
|                         } catch (err) {
 | |
|                           showErrorAlert(err);
 | |
|                         } finally {
 | |
|                           if (context.mounted) hideLoadingModal(context);
 | |
|                         }
 | |
|                       },
 | |
|                       child: Text('confirm'.tr()),
 | |
|                     ),
 | |
|                   ],
 | |
|                 ).padding(horizontal: 16, vertical: 8),
 | |
|               ],
 | |
|             ),
 | |
|           ),
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context, WidgetRef ref) {
 | |
|     var ratio =
 | |
|         item.isOnCloud
 | |
|             ? (item.data.fileMeta?['ratio'] is num
 | |
|                 ? item.data.fileMeta!['ratio'].toDouble()
 | |
|                 : 1.0)
 | |
|             : 1.0;
 | |
|     if (ratio == 0) ratio = 1.0;
 | |
| 
 | |
|     final contentWidget = ClipRRect(
 | |
|       borderRadius: BorderRadius.circular(8),
 | |
|       child: Container(
 | |
|         color: Theme.of(context).colorScheme.surfaceContainer,
 | |
|         child: Stack(
 | |
|           children: [
 | |
|             AspectRatio(
 | |
|               aspectRatio: ratio,
 | |
|               child: Stack(
 | |
|                 fit: StackFit.expand,
 | |
|                 children: [
 | |
|                   Builder(
 | |
|                     key: ValueKey(item.hashCode),
 | |
|                     builder: (context) {
 | |
|                       final fallbackIcon = switch (item.type) {
 | |
|                         UniversalFileType.video => Symbols.video_file,
 | |
|                         UniversalFileType.audio => Symbols.audio_file,
 | |
|                         UniversalFileType.image => Symbols.image,
 | |
|                         _ => Symbols.insert_drive_file,
 | |
|                       };
 | |
| 
 | |
|                       final mimeType = FileUploader.getMimeType(item);
 | |
| 
 | |
|                       if (item.isOnCloud) {
 | |
|                         return CloudFileWidget(item: item.data);
 | |
|                       } else if (item.data is XFile) {
 | |
|                         final file = item.data as XFile;
 | |
|                         if (file.path.isEmpty) {
 | |
|                           return FutureBuilder<Uint8List>(
 | |
|                             future: file.readAsBytes(),
 | |
|                             builder: (context, snapshot) {
 | |
|                               if (snapshot.hasData) {
 | |
|                                 return Image.memory(snapshot.data!);
 | |
|                               }
 | |
|                               return const Center(
 | |
|                                 child: CircularProgressIndicator(),
 | |
|                               );
 | |
|                             },
 | |
|                           );
 | |
|                         }
 | |
| 
 | |
|                         switch (item.type) {
 | |
|                           case UniversalFileType.image:
 | |
|                             return kIsWeb
 | |
|                                 ? Image.network(file.path)
 | |
|                                 : Image.file(File(file.path));
 | |
|                           default:
 | |
|                             return Column(
 | |
|                               mainAxisAlignment: MainAxisAlignment.center,
 | |
|                               children: [
 | |
|                                 Icon(fallbackIcon),
 | |
|                                 const Gap(6),
 | |
|                                 Text(
 | |
|                                   _getDisplayName(),
 | |
|                                   textAlign: TextAlign.center,
 | |
|                                 ),
 | |
|                                 Text(mimeType, style: TextStyle(fontSize: 10)),
 | |
|                                 const Gap(1),
 | |
|                                 FutureBuilder(
 | |
|                                   future: file.length(),
 | |
|                                   builder: (context, snapshot) {
 | |
|                                     if (snapshot.hasData) {
 | |
|                                       final size = snapshot.data as int;
 | |
|                                       return Text(
 | |
|                                         formatFileSize(size),
 | |
|                                       ).fontSize(11);
 | |
|                                     }
 | |
|                                     return const SizedBox.shrink();
 | |
|                                   },
 | |
|                                 ),
 | |
|                               ],
 | |
|                             );
 | |
|                         }
 | |
|                       } else if (item is List<int> || item is Uint8List) {
 | |
|                         switch (item.type) {
 | |
|                           case UniversalFileType.image:
 | |
|                             return Image.memory(item.data);
 | |
|                           default:
 | |
|                             return Column(
 | |
|                               mainAxisAlignment: MainAxisAlignment.center,
 | |
|                               children: [
 | |
|                                 Icon(fallbackIcon),
 | |
|                                 const Gap(6),
 | |
|                                 Text(mimeType, style: TextStyle(fontSize: 10)),
 | |
|                                 const Gap(1),
 | |
|                                 Text(
 | |
|                                   formatFileSize(item.data.length),
 | |
|                                 ).fontSize(11),
 | |
|                               ],
 | |
|                             );
 | |
|                         }
 | |
|                       }
 | |
|                       return Placeholder();
 | |
|                     },
 | |
|                   ),
 | |
|                   if (progress != null)
 | |
|                     Positioned.fill(
 | |
|                       child: Container(
 | |
|                         color: Colors.black.withOpacity(0.3),
 | |
|                         padding: EdgeInsets.symmetric(
 | |
|                           horizontal: 40,
 | |
|                           vertical: 16,
 | |
|                         ),
 | |
|                         child: Column(
 | |
|                           mainAxisAlignment: MainAxisAlignment.center,
 | |
|                           crossAxisAlignment: CrossAxisAlignment.center,
 | |
|                           children: [
 | |
|                             if (progress != null)
 | |
|                               Text(
 | |
|                                 '${progress!.toStringAsFixed(2)}%',
 | |
|                                 style: TextStyle(color: Colors.white),
 | |
|                               )
 | |
|                             else
 | |
|                               Text(
 | |
|                                 'uploading'.tr(),
 | |
|                                 style: TextStyle(color: Colors.white),
 | |
|                               ),
 | |
|                             Gap(6),
 | |
|                             Center(
 | |
|                               child: LinearProgressIndicator(
 | |
|                                 value:
 | |
|                                     progress != null ? progress! / 100.0 : null,
 | |
|                               ),
 | |
|                             ),
 | |
|                           ],
 | |
|                         ),
 | |
|                       ),
 | |
|                     ),
 | |
|                 ],
 | |
|               ),
 | |
|             ).center(),
 | |
|             Row(
 | |
|               mainAxisAlignment: MainAxisAlignment.spaceBetween,
 | |
|               children: [
 | |
|                 ClipRRect(
 | |
|                   borderRadius: BorderRadius.circular(8),
 | |
|                   child: Container(
 | |
|                     color: Colors.black.withOpacity(0.5),
 | |
|                     child: Material(
 | |
|                       color: Colors.transparent,
 | |
|                       child: Row(
 | |
|                         mainAxisSize: MainAxisSize.min,
 | |
|                         children: [
 | |
|                           if (onDelete != null)
 | |
|                             InkWell(
 | |
|                               borderRadius: BorderRadius.circular(8),
 | |
|                               child: Icon(
 | |
|                                 item.isLink ? Symbols.link_off : Symbols.delete,
 | |
|                                 size: 14,
 | |
|                                 color: Colors.white,
 | |
|                               ).padding(horizontal: 8, vertical: 6),
 | |
|                               onTap: () {
 | |
|                                 onDelete?.call();
 | |
|                               },
 | |
|                             ),
 | |
|                           if (onDelete != null && onMove != null)
 | |
|                             SizedBox(
 | |
|                               height: 26,
 | |
|                               child: const VerticalDivider(
 | |
|                                 width: 0.3,
 | |
|                                 color: Colors.white,
 | |
|                                 thickness: 0.3,
 | |
|                               ),
 | |
|                             ).padding(horizontal: 2),
 | |
|                           if (onMove != null)
 | |
|                             InkWell(
 | |
|                               borderRadius: BorderRadius.circular(8),
 | |
|                               child: const Icon(
 | |
|                                 Symbols.keyboard_arrow_up,
 | |
|                                 size: 14,
 | |
|                                 color: Colors.white,
 | |
|                               ).padding(horizontal: 8, vertical: 6),
 | |
|                               onTap: () {
 | |
|                                 onMove?.call(-1);
 | |
|                               },
 | |
|                             ),
 | |
|                           if (onMove != null)
 | |
|                             InkWell(
 | |
|                               borderRadius: BorderRadius.circular(8),
 | |
|                               child: const Icon(
 | |
|                                 Symbols.keyboard_arrow_down,
 | |
|                                 size: 14,
 | |
|                                 color: Colors.white,
 | |
|                               ).padding(horizontal: 8, vertical: 6),
 | |
|                               onTap: () {
 | |
|                                 onMove?.call(1);
 | |
|                               },
 | |
|                             ),
 | |
|                           if (onInsert != null)
 | |
|                             InkWell(
 | |
|                               borderRadius: BorderRadius.circular(8),
 | |
|                               child: const Icon(
 | |
|                                 Symbols.add,
 | |
|                                 size: 14,
 | |
|                                 color: Colors.white,
 | |
|                               ).padding(horizontal: 8, vertical: 6),
 | |
|                               onTap: () {
 | |
|                                 onInsert?.call();
 | |
|                               },
 | |
|                             ),
 | |
|                         ],
 | |
|                       ),
 | |
|                     ),
 | |
|                   ),
 | |
|                 ),
 | |
|                 if (onRequestUpload != null)
 | |
|                   InkWell(
 | |
|                     borderRadius: BorderRadius.circular(8),
 | |
|                     onTap:
 | |
|                         item.isOnCloud ? null : () => onRequestUpload?.call(),
 | |
|                     child: ClipRRect(
 | |
|                       borderRadius: BorderRadius.circular(8),
 | |
|                       child: Container(
 | |
|                         color: Colors.black.withOpacity(0.5),
 | |
|                         padding: EdgeInsets.symmetric(
 | |
|                           horizontal: 8,
 | |
|                           vertical: 4,
 | |
|                         ),
 | |
|                         child:
 | |
|                             (item.isOnCloud)
 | |
|                                 ? Row(
 | |
|                                   mainAxisSize: MainAxisSize.min,
 | |
|                                   children: [
 | |
|                                     Icon(
 | |
|                                       Symbols.cloud,
 | |
|                                       size: 16,
 | |
|                                       color: Colors.white,
 | |
|                                     ),
 | |
|                                     if (!isCompact) const Gap(8),
 | |
|                                     if (!isCompact)
 | |
|                                       Text(
 | |
|                                         'attachmentOnCloud'.tr(),
 | |
|                                         style: TextStyle(color: Colors.white),
 | |
|                                       ),
 | |
|                                   ],
 | |
|                                 )
 | |
|                                 : Row(
 | |
|                                   mainAxisSize: MainAxisSize.min,
 | |
|                                   children: [
 | |
|                                     Icon(
 | |
|                                       Symbols.cloud_off,
 | |
|                                       size: 16,
 | |
|                                       color: Colors.white,
 | |
|                                     ),
 | |
|                                     if (!isCompact) const Gap(8),
 | |
|                                     if (!isCompact)
 | |
|                                       Text(
 | |
|                                         'attachmentOnDevice'.tr(),
 | |
|                                         style: TextStyle(color: Colors.white),
 | |
|                                       ),
 | |
|                                   ],
 | |
|                                 ),
 | |
|                       ),
 | |
|                     ),
 | |
|                   ),
 | |
|               ],
 | |
|             ).padding(horizontal: 12, vertical: 8),
 | |
|           ],
 | |
|         ),
 | |
|       ),
 | |
|     );
 | |
| 
 | |
|     return ContextMenuWidget(
 | |
|       menuProvider:
 | |
|           (MenuRequest request) => Menu(
 | |
|             children: [
 | |
|               if (item.isOnDevice && item.type == UniversalFileType.image)
 | |
|                 MenuAction(
 | |
|                   title: 'crop'.tr(),
 | |
|                   image: MenuImage.icon(Symbols.crop),
 | |
|                   callback: () async {
 | |
|                     final result = await cropImage(
 | |
|                       context,
 | |
|                       image: item.data,
 | |
|                       replacePath: true,
 | |
|                     );
 | |
|                     if (result == null) return;
 | |
|                     onUpdate?.call(item.copyWith(data: result));
 | |
|                   },
 | |
|                 ),
 | |
|               if (item.isOnDevice)
 | |
|                 MenuAction(
 | |
|                   title: 'rename'.tr(),
 | |
|                   image: MenuImage.icon(Symbols.edit),
 | |
|                   callback: () async {
 | |
|                     await _showRenameSheet(context, ref);
 | |
|                   },
 | |
|                 ),
 | |
|               if (item.isOnCloud)
 | |
|                 MenuAction(
 | |
|                   title: 'rename'.tr(),
 | |
|                   image: MenuImage.icon(Symbols.edit),
 | |
|                   callback: () async {
 | |
|                     await _showRenameSheet(context, ref);
 | |
|                   },
 | |
|                 ),
 | |
|               if (item.isOnCloud)
 | |
|                 MenuAction(
 | |
|                   title: 'markAsSensitive'.tr(),
 | |
|                   image: MenuImage.icon(Symbols.no_adult_content),
 | |
|                   callback: () async {
 | |
|                     await _showSensitiveDialog(context, ref);
 | |
|                   },
 | |
|                 ),
 | |
|             ],
 | |
|           ),
 | |
|       child: contentWidget,
 | |
|     );
 | |
|   }
 | |
| }
 |