diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index 003b12c..6811e6c 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -737,5 +737,21 @@ "repliesLoadMore": "Load more replies", "attachmentsRecentUploads": "Recent Uploads", "attachmentsManualInput": "Manual Input", - "crop": "Crop" + "crop": "Crop", + "rename": "Rename", + "markAsSensitive": "Mark as Sensitive", + "fileName": "File name", + "sensitiveCategories.language": "Language", + "sensitiveCategories.sexualContent": "Sexual Content", + "sensitiveCategories.violence": "Violence", + "sensitiveCategories.profanity": "Profanity", + "sensitiveCategories.hateSpeech": "Hate Speech", + "sensitiveCategories.racism": "Racism", + "sensitiveCategories.adultContent": "Adult Content", + "sensitiveCategories.drugAbuse": "Drug Abuse", + "sensitiveCategories.alcoholAbuse": "Alcohol Abuse", + "sensitiveCategories.gambling": "Gambling", + "sensitiveCategories.selfHarm": "Self-harm", + "sensitiveCategories.childAbuse": "Child Abuse", + "sensitiveCategories.other": "Other" } diff --git a/lib/models/file.dart b/lib/models/file.dart index 9b2ed2e..e889c91 100644 --- a/lib/models/file.dart +++ b/lib/models/file.dart @@ -42,6 +42,7 @@ sealed class SnCloudFile with _$SnCloudFile { required String? description, required Map? fileMeta, required Map? userMeta, + @Default([]) List sensitiveMarks, required String? mimeType, required String? hash, required int size, diff --git a/lib/models/file.freezed.dart b/lib/models/file.freezed.dart index 347ea8d..ed28073 100644 --- a/lib/models/file.freezed.dart +++ b/lib/models/file.freezed.dart @@ -278,7 +278,7 @@ as bool, /// @nodoc mixin _$SnCloudFile { - String get id; String get name; String? get description; Map? get fileMeta; Map? get userMeta; String? get mimeType; String? get hash; int get size; DateTime? get uploadedAt; String? get uploadedTo; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; + String get id; String get name; String? get description; Map? get fileMeta; Map? get userMeta; List get sensitiveMarks; String? get mimeType; String? get hash; int get size; DateTime? get uploadedAt; String? get uploadedTo; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; /// Create a copy of SnCloudFile /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -291,16 +291,16 @@ $SnCloudFileCopyWith get copyWith => _$SnCloudFileCopyWithImpl Object.hash(runtimeType,id,name,description,const DeepCollectionEquality().hash(fileMeta),const DeepCollectionEquality().hash(userMeta),mimeType,hash,size,uploadedAt,uploadedTo,createdAt,updatedAt,deletedAt); +int get hashCode => Object.hash(runtimeType,id,name,description,const DeepCollectionEquality().hash(fileMeta),const DeepCollectionEquality().hash(userMeta),const DeepCollectionEquality().hash(sensitiveMarks),mimeType,hash,size,uploadedAt,uploadedTo,createdAt,updatedAt,deletedAt); @override String toString() { - return 'SnCloudFile(id: $id, name: $name, description: $description, fileMeta: $fileMeta, userMeta: $userMeta, mimeType: $mimeType, hash: $hash, size: $size, uploadedAt: $uploadedAt, uploadedTo: $uploadedTo, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; + return 'SnCloudFile(id: $id, name: $name, description: $description, fileMeta: $fileMeta, userMeta: $userMeta, sensitiveMarks: $sensitiveMarks, mimeType: $mimeType, hash: $hash, size: $size, uploadedAt: $uploadedAt, uploadedTo: $uploadedTo, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; } @@ -311,7 +311,7 @@ abstract mixin class $SnCloudFileCopyWith<$Res> { factory $SnCloudFileCopyWith(SnCloudFile value, $Res Function(SnCloudFile) _then) = _$SnCloudFileCopyWithImpl; @useResult $Res call({ - String id, String name, String? description, Map? fileMeta, Map? userMeta, String? mimeType, String? hash, int size, DateTime? uploadedAt, String? uploadedTo, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt + String id, String name, String? description, Map? fileMeta, Map? userMeta, List sensitiveMarks, String? mimeType, String? hash, int size, DateTime? uploadedAt, String? uploadedTo, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt }); @@ -328,14 +328,15 @@ class _$SnCloudFileCopyWithImpl<$Res> /// Create a copy of SnCloudFile /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? name = null,Object? description = freezed,Object? fileMeta = freezed,Object? userMeta = freezed,Object? mimeType = freezed,Object? hash = freezed,Object? size = null,Object? uploadedAt = freezed,Object? uploadedTo = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? name = null,Object? description = freezed,Object? fileMeta = freezed,Object? userMeta = freezed,Object? sensitiveMarks = null,Object? mimeType = freezed,Object? hash = freezed,Object? size = null,Object? uploadedAt = freezed,Object? uploadedTo = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { return _then(_self.copyWith( id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable as String,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable as String?,fileMeta: freezed == fileMeta ? _self.fileMeta : fileMeta // ignore: cast_nullable_to_non_nullable as Map?,userMeta: freezed == userMeta ? _self.userMeta : userMeta // ignore: cast_nullable_to_non_nullable -as Map?,mimeType: freezed == mimeType ? _self.mimeType : mimeType // ignore: cast_nullable_to_non_nullable +as Map?,sensitiveMarks: null == sensitiveMarks ? _self.sensitiveMarks : sensitiveMarks // ignore: cast_nullable_to_non_nullable +as List,mimeType: freezed == mimeType ? _self.mimeType : mimeType // ignore: cast_nullable_to_non_nullable as String?,hash: freezed == hash ? _self.hash : hash // ignore: cast_nullable_to_non_nullable as String?,size: null == size ? _self.size : size // ignore: cast_nullable_to_non_nullable as int,uploadedAt: freezed == uploadedAt ? _self.uploadedAt : uploadedAt // ignore: cast_nullable_to_non_nullable @@ -425,10 +426,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( String id, String name, String? description, Map? fileMeta, Map? userMeta, String? mimeType, String? hash, int size, DateTime? uploadedAt, String? uploadedTo, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( String id, String name, String? description, Map? fileMeta, Map? userMeta, List sensitiveMarks, String? mimeType, String? hash, int size, DateTime? uploadedAt, String? uploadedTo, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _SnCloudFile() when $default != null: -return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userMeta,_that.mimeType,_that.hash,_that.size,_that.uploadedAt,_that.uploadedTo,_that.createdAt,_that.updatedAt,_that.deletedAt);case _: +return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userMeta,_that.sensitiveMarks,_that.mimeType,_that.hash,_that.size,_that.uploadedAt,_that.uploadedTo,_that.createdAt,_that.updatedAt,_that.deletedAt);case _: return orElse(); } @@ -446,10 +447,10 @@ return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userM /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( String id, String name, String? description, Map? fileMeta, Map? userMeta, String? mimeType, String? hash, int size, DateTime? uploadedAt, String? uploadedTo, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( String id, String name, String? description, Map? fileMeta, Map? userMeta, List sensitiveMarks, String? mimeType, String? hash, int size, DateTime? uploadedAt, String? uploadedTo, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt) $default,) {final _that = this; switch (_that) { case _SnCloudFile(): -return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userMeta,_that.mimeType,_that.hash,_that.size,_that.uploadedAt,_that.uploadedTo,_that.createdAt,_that.updatedAt,_that.deletedAt);} +return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userMeta,_that.sensitiveMarks,_that.mimeType,_that.hash,_that.size,_that.uploadedAt,_that.uploadedTo,_that.createdAt,_that.updatedAt,_that.deletedAt);} } /// A variant of `when` that fallback to returning `null` /// @@ -463,10 +464,10 @@ return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userM /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, String name, String? description, Map? fileMeta, Map? userMeta, String? mimeType, String? hash, int size, DateTime? uploadedAt, String? uploadedTo, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, String name, String? description, Map? fileMeta, Map? userMeta, List sensitiveMarks, String? mimeType, String? hash, int size, DateTime? uploadedAt, String? uploadedTo, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,) {final _that = this; switch (_that) { case _SnCloudFile() when $default != null: -return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userMeta,_that.mimeType,_that.hash,_that.size,_that.uploadedAt,_that.uploadedTo,_that.createdAt,_that.updatedAt,_that.deletedAt);case _: +return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userMeta,_that.sensitiveMarks,_that.mimeType,_that.hash,_that.size,_that.uploadedAt,_that.uploadedTo,_that.createdAt,_that.updatedAt,_that.deletedAt);case _: return null; } @@ -478,7 +479,7 @@ return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userM @JsonSerializable() class _SnCloudFile implements SnCloudFile { - const _SnCloudFile({required this.id, required this.name, required this.description, required final Map? fileMeta, required final Map? userMeta, required this.mimeType, required this.hash, required this.size, required this.uploadedAt, required this.uploadedTo, required this.createdAt, required this.updatedAt, required this.deletedAt}): _fileMeta = fileMeta,_userMeta = userMeta; + const _SnCloudFile({required this.id, required this.name, required this.description, required final Map? fileMeta, required final Map? userMeta, final List sensitiveMarks = const [], required this.mimeType, required this.hash, required this.size, required this.uploadedAt, required this.uploadedTo, required this.createdAt, required this.updatedAt, required this.deletedAt}): _fileMeta = fileMeta,_userMeta = userMeta,_sensitiveMarks = sensitiveMarks; factory _SnCloudFile.fromJson(Map json) => _$SnCloudFileFromJson(json); @override final String id; @@ -502,6 +503,13 @@ class _SnCloudFile implements SnCloudFile { return EqualUnmodifiableMapView(value); } + final List _sensitiveMarks; +@override@JsonKey() List get sensitiveMarks { + if (_sensitiveMarks is EqualUnmodifiableListView) return _sensitiveMarks; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_sensitiveMarks); +} + @override final String? mimeType; @override final String? hash; @override final int size; @@ -524,16 +532,16 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnCloudFile&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&const DeepCollectionEquality().equals(other._fileMeta, _fileMeta)&&const DeepCollectionEquality().equals(other._userMeta, _userMeta)&&(identical(other.mimeType, mimeType) || other.mimeType == mimeType)&&(identical(other.hash, hash) || other.hash == hash)&&(identical(other.size, size) || other.size == size)&&(identical(other.uploadedAt, uploadedAt) || other.uploadedAt == uploadedAt)&&(identical(other.uploadedTo, uploadedTo) || other.uploadedTo == uploadedTo)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnCloudFile&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&const DeepCollectionEquality().equals(other._fileMeta, _fileMeta)&&const DeepCollectionEquality().equals(other._userMeta, _userMeta)&&const DeepCollectionEquality().equals(other._sensitiveMarks, _sensitiveMarks)&&(identical(other.mimeType, mimeType) || other.mimeType == mimeType)&&(identical(other.hash, hash) || other.hash == hash)&&(identical(other.size, size) || other.size == size)&&(identical(other.uploadedAt, uploadedAt) || other.uploadedAt == uploadedAt)&&(identical(other.uploadedTo, uploadedTo) || other.uploadedTo == uploadedTo)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,id,name,description,const DeepCollectionEquality().hash(_fileMeta),const DeepCollectionEquality().hash(_userMeta),mimeType,hash,size,uploadedAt,uploadedTo,createdAt,updatedAt,deletedAt); +int get hashCode => Object.hash(runtimeType,id,name,description,const DeepCollectionEquality().hash(_fileMeta),const DeepCollectionEquality().hash(_userMeta),const DeepCollectionEquality().hash(_sensitiveMarks),mimeType,hash,size,uploadedAt,uploadedTo,createdAt,updatedAt,deletedAt); @override String toString() { - return 'SnCloudFile(id: $id, name: $name, description: $description, fileMeta: $fileMeta, userMeta: $userMeta, mimeType: $mimeType, hash: $hash, size: $size, uploadedAt: $uploadedAt, uploadedTo: $uploadedTo, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; + return 'SnCloudFile(id: $id, name: $name, description: $description, fileMeta: $fileMeta, userMeta: $userMeta, sensitiveMarks: $sensitiveMarks, mimeType: $mimeType, hash: $hash, size: $size, uploadedAt: $uploadedAt, uploadedTo: $uploadedTo, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; } @@ -544,7 +552,7 @@ abstract mixin class _$SnCloudFileCopyWith<$Res> implements $SnCloudFileCopyWith factory _$SnCloudFileCopyWith(_SnCloudFile value, $Res Function(_SnCloudFile) _then) = __$SnCloudFileCopyWithImpl; @override @useResult $Res call({ - String id, String name, String? description, Map? fileMeta, Map? userMeta, String? mimeType, String? hash, int size, DateTime? uploadedAt, String? uploadedTo, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt + String id, String name, String? description, Map? fileMeta, Map? userMeta, List sensitiveMarks, String? mimeType, String? hash, int size, DateTime? uploadedAt, String? uploadedTo, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt }); @@ -561,14 +569,15 @@ class __$SnCloudFileCopyWithImpl<$Res> /// Create a copy of SnCloudFile /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = null,Object? description = freezed,Object? fileMeta = freezed,Object? userMeta = freezed,Object? mimeType = freezed,Object? hash = freezed,Object? size = null,Object? uploadedAt = freezed,Object? uploadedTo = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = null,Object? description = freezed,Object? fileMeta = freezed,Object? userMeta = freezed,Object? sensitiveMarks = null,Object? mimeType = freezed,Object? hash = freezed,Object? size = null,Object? uploadedAt = freezed,Object? uploadedTo = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { return _then(_SnCloudFile( id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable as String,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable as String?,fileMeta: freezed == fileMeta ? _self._fileMeta : fileMeta // ignore: cast_nullable_to_non_nullable as Map?,userMeta: freezed == userMeta ? _self._userMeta : userMeta // ignore: cast_nullable_to_non_nullable -as Map?,mimeType: freezed == mimeType ? _self.mimeType : mimeType // ignore: cast_nullable_to_non_nullable +as Map?,sensitiveMarks: null == sensitiveMarks ? _self._sensitiveMarks : sensitiveMarks // ignore: cast_nullable_to_non_nullable +as List,mimeType: freezed == mimeType ? _self.mimeType : mimeType // ignore: cast_nullable_to_non_nullable as String?,hash: freezed == hash ? _self.hash : hash // ignore: cast_nullable_to_non_nullable as String?,size: null == size ? _self.size : size // ignore: cast_nullable_to_non_nullable as int,uploadedAt: freezed == uploadedAt ? _self.uploadedAt : uploadedAt // ignore: cast_nullable_to_non_nullable diff --git a/lib/models/file.g.dart b/lib/models/file.g.dart index 4fe0117..62577dc 100644 --- a/lib/models/file.g.dart +++ b/lib/models/file.g.dart @@ -33,6 +33,11 @@ _SnCloudFile _$SnCloudFileFromJson(Map json) => _SnCloudFile( description: json['description'] as String?, fileMeta: json['file_meta'] as Map?, userMeta: json['user_meta'] as Map?, + sensitiveMarks: + (json['sensitive_marks'] as List?) + ?.map((e) => (e as num).toInt()) + .toList() ?? + const [], mimeType: json['mime_type'] as String?, hash: json['hash'] as String?, size: (json['size'] as num).toInt(), @@ -56,6 +61,7 @@ Map _$SnCloudFileToJson(_SnCloudFile instance) => 'description': instance.description, 'file_meta': instance.fileMeta, 'user_meta': instance.userMeta, + 'sensitive_marks': instance.sensitiveMarks, 'mime_type': instance.mimeType, 'hash': instance.hash, 'size': instance.size, diff --git a/lib/widgets/content/attachment_preview.dart b/lib/widgets/content/attachment_preview.dart index 3ddcad1..570aa30 100644 --- a/lib/widgets/content/attachment_preview.dart +++ b/lib/widgets/content/attachment_preview.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:io'; import 'package:cross_file/cross_file.dart'; @@ -5,14 +6,81 @@ 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'; -class AttachmentPreview extends StatelessWidget { +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; @@ -20,6 +88,7 @@ class AttachmentPreview extends StatelessWidget { final Function? onInsert; final Function(UniversalFile)? onUpdate; final Function? onRequestUpload; + const AttachmentPreview({ super.key, required this.item, @@ -31,8 +100,166 @@ class AttachmentPreview extends StatelessWidget { 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) { + Widget build(BuildContext context, WidgetRef ref) { var ratio = item.isOnCloud ? (item.data.fileMeta?['ratio'] is num @@ -281,6 +508,22 @@ class AttachmentPreview extends StatelessWidget { 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, diff --git a/lib/widgets/content/sensitive.dart b/lib/widgets/content/sensitive.dart new file mode 100644 index 0000000..93a2a9d --- /dev/null +++ b/lib/widgets/content/sensitive.dart @@ -0,0 +1,71 @@ +// Copyright (c) Solsynth +// Sensitive content categories for content warnings, in fixed order. + +enum SensitiveCategory { + language, + sexualContent, + violence, + profanity, + hateSpeech, + racism, + adultContent, + drugAbuse, + alcoholAbuse, + gambling, + selfHarm, + childAbuse, + other, +} + +extension SensitiveCategoryI18n on SensitiveCategory { + /// i18n key to look up localized label + String get i18nKey => switch (this) { + SensitiveCategory.language => 'sensitiveCategories.language', + SensitiveCategory.sexualContent => 'sensitiveCategories.sexualContent', + SensitiveCategory.violence => 'sensitiveCategories.violence', + SensitiveCategory.profanity => 'sensitiveCategories.profanity', + SensitiveCategory.hateSpeech => 'sensitiveCategories.hateSpeech', + SensitiveCategory.racism => 'sensitiveCategories.racism', + SensitiveCategory.adultContent => 'sensitiveCategories.adultContent', + SensitiveCategory.drugAbuse => 'sensitiveCategories.drugAbuse', + SensitiveCategory.alcoholAbuse => 'sensitiveCategories.alcoholAbuse', + SensitiveCategory.gambling => 'sensitiveCategories.gambling', + SensitiveCategory.selfHarm => 'sensitiveCategories.selfHarm', + SensitiveCategory.childAbuse => 'sensitiveCategories.childAbuse', + SensitiveCategory.other => 'sensitiveCategories.other', + }; + + /// Optional symbol you can use alongside the label in UI + String get symbol => switch (this) { + SensitiveCategory.language => '🌐', + SensitiveCategory.sexualContent => '🔞', + SensitiveCategory.violence => '⚠️', + SensitiveCategory.profanity => '🗯️', + SensitiveCategory.hateSpeech => '🚫', + SensitiveCategory.racism => '✋', + SensitiveCategory.adultContent => '🍑', + SensitiveCategory.drugAbuse => '💊', + SensitiveCategory.alcoholAbuse => '🍺', + SensitiveCategory.gambling => '🎲', + SensitiveCategory.selfHarm => '🆘', + SensitiveCategory.childAbuse => '🛑', + SensitiveCategory.other => '❗', + }; +} + +/// Ordered list for UI consumption, matching enum declaration order. +const List kSensitiveCategoriesOrdered = [ + SensitiveCategory.language, + SensitiveCategory.sexualContent, + SensitiveCategory.violence, + SensitiveCategory.profanity, + SensitiveCategory.hateSpeech, + SensitiveCategory.racism, + SensitiveCategory.adultContent, + SensitiveCategory.drugAbuse, + SensitiveCategory.alcoholAbuse, + SensitiveCategory.gambling, + SensitiveCategory.selfHarm, + SensitiveCategory.childAbuse, + SensitiveCategory.other, +];