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/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 initial; final ValueChanged>? onChanged; const SensitiveMarksSelector({ super.key, required this.initial, this.onChanged, }); @override State createState() => SensitiveMarksSelectorState(); } class SensitiveMarksSelectorState extends State { late List _selected; List 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; const AttachmentPreview({ super.key, required this.item, this.progress, this.onRequestUpload, this.onMove, this.onDelete, this.onUpdate, this.onInsert, }); // GlobalKey for selector static final GlobalKey _sensitiveSelectorKey = GlobalKey(); Future _showRenameDialog(BuildContext context, WidgetRef ref) async { final nameController = TextEditingController(text: item.data.name); String? errorMessage; await showModalBottomSheet( context: context, isScrollControlled: 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; } 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; final updatedFile = item.copyWith(data: newData); onUpdate?.call(item.copyWith(data: updatedFile)); if (context.mounted) Navigator.pop(context); } catch (err) { showErrorAlert(err); } finally { if (context.mounted) hideLoadingModal(context); } }, child: Text('rename'.tr()), ), ], ).padding(horizontal: 16, vertical: 8), ], ), ), ); } Future _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() .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 ?? []; 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: Column( children: [ 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: () => 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, ), const Gap(8), Text( 'On-cloud', style: TextStyle(color: Colors.white), ), ], ) : Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Symbols.cloud_off, size: 16, color: Colors.white, ), const Gap(8), Text( 'On-device', style: TextStyle(color: Colors.white), ), ], ), ), ), ), ], ).padding(horizontal: 12, vertical: 8), AspectRatio( aspectRatio: ratio, child: Stack( fit: StackFit.expand, children: [ Builder( key: ValueKey(item.hashCode), builder: (context) { 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( 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( children: [ const Icon(Symbols.document_scanner), Text(file.name), ], ); } } else if (item is List || item is Uint8List) { switch (item.type) { case UniversalFileType.image: return Image.memory(item.data); default: return Column( children: [const Icon(Symbols.document_scanner)], ); } } 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, ), ), ], ), ), ), ], ), ), ], ), ), ); 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.isOnCloud) MenuAction( title: 'rename'.tr(), image: MenuImage.icon(Symbols.edit), callback: () async { await _showRenameDialog(context, ref); }, ), if (item.isOnCloud) MenuAction( title: 'markAsSensitive'.tr(), image: MenuImage.icon(Symbols.no_adult_content), callback: () async { await _showSensitiveDialog(context, ref); }, ), ], ), child: contentWidget, ); } }