File rename, sensitive

This commit is contained in:
2025-08-05 00:53:00 +08:00
parent 6ae6b132de
commit b976c6ed37
6 changed files with 369 additions and 23 deletions

View File

@@ -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<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;
@@ -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<SensitiveMarksSelectorState> _sensitiveSelectorKey =
GlobalKey<SensitiveMarksSelectorState>();
Future<void> _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<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) {
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,

View File

@@ -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<SensitiveCategory> 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,
];