mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-15 12:53:41 +00:00
feat(download): auto-categorize downloads into folders by file extension (#1572)
This commit is contained in:
1 parent
d125d4ab75
commit
9aae13453a
55 files changed
+4403
-217
No files matched your search
+85
-29
@@ -11,12 +11,14 @@ import '../../../../shared/widgets/app_path_placeholder_button.dart';
|
||||
import '../../../../shared/widgets/app_primary_button.dart';
|
||||
import '../../../../shared/widgets/app_text_field.dart';
|
||||
import '../../../../l10n/l10n.dart';
|
||||
import 'extension_tag_editor.dart';
|
||||
|
||||
class DownloadCategoryDraft {
|
||||
const DownloadCategoryDraft({required this.name, required this.path});
|
||||
const DownloadCategoryDraft({required this.name, required this.path, required this.extensions});
|
||||
|
||||
final String name;
|
||||
final String path;
|
||||
final List<String> extensions;
|
||||
}
|
||||
|
||||
class DownloadCategoriesControl extends StatelessWidget {
|
||||
@@ -93,6 +95,7 @@ class _CategoryRow extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = AppPalette.of(context);
|
||||
final extensions = category.effectiveExtensions();
|
||||
return Row(
|
||||
key: ValueKey('download-category-${category.nameKey ?? category.name}-${category.path}'),
|
||||
children: [
|
||||
@@ -111,6 +114,16 @@ class _CategoryRow extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: palette.textSecondary, fontSize: 12),
|
||||
),
|
||||
if (extensions.isNotEmpty) ...[
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
extensions.join(', '),
|
||||
key: const ValueKey('download-category-extensions'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: palette.textSecondary, fontSize: 11),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -137,9 +150,29 @@ Future<DownloadCategoryDraft?> showDownloadCategoryDialog(
|
||||
DownloadCategory? category,
|
||||
required String initialName,
|
||||
required String initialPath,
|
||||
required List<DownloadCategory> categories,
|
||||
required String Function(DownloadCategory) displayName,
|
||||
}) async {
|
||||
final nameController = TextEditingController(text: initialName);
|
||||
final pathController = TextEditingController(text: category?.path ?? initialPath);
|
||||
final editorKey = GlobalKey<ExtensionTagEditorState>();
|
||||
final nameKey = category?.nameKey;
|
||||
final restoreDefaults = nameKey == null
|
||||
? const <String>[]
|
||||
: normalizeCategoryExtensions(kDefaultCategoryExtensions[nameKey] ?? const []);
|
||||
// Extensions every other live category already owns, mapped to its
|
||||
// display name; the edited category itself is exempt.
|
||||
final takenExtensions = <String, String>{};
|
||||
for (final other in categories) {
|
||||
if (other.isDeleted || identical(other, category)) continue;
|
||||
for (final extension in normalizeCategoryExtensions(other.effectiveExtensions())) {
|
||||
takenExtensions.putIfAbsent(extension, () => displayName(other));
|
||||
}
|
||||
}
|
||||
|
||||
var extensionConflict = normalizeCategoryExtensions(
|
||||
category?.effectiveExtensions() ?? const [],
|
||||
).any(takenExtensions.containsKey);
|
||||
String? validationMessage;
|
||||
|
||||
final overlay = const shad.DialogOverlayHandler().show<DownloadCategoryDraft?>(
|
||||
@@ -150,14 +183,23 @@ Future<DownloadCategoryDraft?> showDownloadCategoryDialog(
|
||||
builder: (dialogContext, setDialogState) {
|
||||
final palette = AppPalette.of(dialogContext);
|
||||
|
||||
void reportExtensionConflict(bool value) {
|
||||
if (extensionConflict == value) return;
|
||||
setDialogState(() => extensionConflict = value);
|
||||
}
|
||||
|
||||
void submit() {
|
||||
if (extensionConflict) return;
|
||||
final name = nameController.text.trim();
|
||||
final path = pathController.text.trim();
|
||||
if (name.isEmpty || path.isEmpty) {
|
||||
setDialogState(() => validationMessage = dialogContext.l10n.categoryFieldsRequired);
|
||||
return;
|
||||
}
|
||||
shad.closeOverlay(dialogContext, DownloadCategoryDraft(name: name, path: path));
|
||||
shad.closeOverlay(
|
||||
dialogContext,
|
||||
DownloadCategoryDraft(name: name, path: path, extensions: editorKey.currentState?.flush() ?? const []),
|
||||
);
|
||||
}
|
||||
|
||||
return shad.AlertDialog(
|
||||
@@ -165,35 +207,49 @@ Future<DownloadCategoryDraft?> showDownloadCategoryDialog(
|
||||
content: SizedBox(
|
||||
key: const ValueKey('download-category-dialog'),
|
||||
width: (MediaQuery.sizeOf(dialogContext).width - 64).clamp(260.0, 420.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(dialogContext.l10n.categoryName, style: TextStyle(color: palette.textSecondary, fontSize: 12)),
|
||||
const SizedBox(height: 6),
|
||||
AppTextField(key: const ValueKey('download-category-name'), controller: nameController),
|
||||
const SizedBox(height: 14),
|
||||
Text(dialogContext.l10n.categoryPath, style: TextStyle(color: palette.textSecondary, fontSize: 12)),
|
||||
const SizedBox(height: 6),
|
||||
AppPathPickerField.downloadDirectory(
|
||||
fieldKey: const ValueKey('download-category-path'),
|
||||
controller: pathController,
|
||||
desktopWidth: AppDesignTokens.settingsFormControlWidth,
|
||||
allowAndroidEditing: true,
|
||||
),
|
||||
const SizedBox(height: AppDesignTokens.space4),
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: AppPathPlaceholderButton(
|
||||
key: const ValueKey('download-category-placeholders'),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(dialogContext.l10n.categoryName, style: TextStyle(color: palette.textSecondary, fontSize: 12)),
|
||||
const SizedBox(height: 6),
|
||||
AppTextField(key: const ValueKey('download-category-name'), controller: nameController),
|
||||
const SizedBox(height: 14),
|
||||
Text(dialogContext.l10n.categoryPath, style: TextStyle(color: palette.textSecondary, fontSize: 12)),
|
||||
const SizedBox(height: 6),
|
||||
AppPathPickerField.downloadDirectory(
|
||||
fieldKey: const ValueKey('download-category-path'),
|
||||
controller: pathController,
|
||||
desktopWidth: AppDesignTokens.settingsFormControlWidth,
|
||||
allowAndroidEditing: true,
|
||||
),
|
||||
),
|
||||
if (validationMessage != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(validationMessage!, style: TextStyle(color: palette.error, fontSize: 12)),
|
||||
const SizedBox(height: AppDesignTokens.space4),
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: AppPathPlaceholderButton(
|
||||
key: const ValueKey('download-category-placeholders'),
|
||||
controller: pathController,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
ExtensionTagEditor(
|
||||
key: editorKey,
|
||||
inputKey: const ValueKey('download-category-extensions-input'),
|
||||
initialTags: category?.effectiveExtensions() ?? const [],
|
||||
labelText: dialogContext.l10n.categoryExtensions,
|
||||
placeholderText: dialogContext.l10n.categoryExtensionsEmptyHint,
|
||||
usagePlaceholderText: dialogContext.l10n.categoryExtensionsUsageHint,
|
||||
restoreDefaults: restoreDefaults,
|
||||
takenByCategory: takenExtensions,
|
||||
onConflictChanged: reportExtensionConflict,
|
||||
),
|
||||
if (validationMessage != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(validationMessage!, style: TextStyle(color: palette.error, fontSize: 12)),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
@@ -201,7 +257,7 @@ Future<DownloadCategoryDraft?> showDownloadCategoryDialog(
|
||||
onPressed: () => shad.closeOverlay(dialogContext),
|
||||
child: Text(dialogContext.l10n.cancel),
|
||||
),
|
||||
AppPrimaryButton(onPressed: submit, child: Text(dialogContext.l10n.save)),
|
||||
AppPrimaryButton(onPressed: extensionConflict ? null : submit, child: Text(dialogContext.l10n.save)),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
import 'package:flutter/material.dart' show Icons;
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
|
||||
import '../../../../api/model/downloader_config.dart';
|
||||
import '../../../../l10n/l10n.dart';
|
||||
import '../../../../shared/theme/app_design_tokens.dart';
|
||||
import '../../../../shared/theme/app_palette.dart';
|
||||
import '../../../../shared/widgets/app_text_field.dart';
|
||||
|
||||
/// Tag style editor for file extension lists.
|
||||
///
|
||||
/// Typed text stays in the input until the submit action (Enter) commits it;
|
||||
/// commas and whitespace split the committed text into several tags, so
|
||||
/// `a b,c` becomes `a`, `b` and `c`. Every token goes through
|
||||
/// [normalizeCategoryExtensions], so the tags are trimmed, lowercased, dot
|
||||
/// free and deduplicated while keeping the input order. While typing, chips
|
||||
/// whose tag matches the pending input are highlighted to surface
|
||||
/// duplicates before they are committed. When the editor is empty and
|
||||
/// [restoreDefaults] is provided, an outline restore action sits at the end
|
||||
/// of the [labelText] row and refills the tags. [takenByCategory] lists
|
||||
/// extensions already claimed by another category: those tokens are never
|
||||
/// committed, their chips render with the error border, and the editor
|
||||
/// reports the conflict through [onConflictChanged] so the host can block
|
||||
/// submission while one is pending.
|
||||
class ExtensionTagEditor extends StatefulWidget {
|
||||
const ExtensionTagEditor({
|
||||
super.key,
|
||||
this.inputKey,
|
||||
required this.initialTags,
|
||||
this.labelText,
|
||||
this.placeholderText,
|
||||
this.usagePlaceholderText,
|
||||
this.restoreDefaults,
|
||||
this.takenByCategory,
|
||||
this.onConflictChanged,
|
||||
});
|
||||
|
||||
/// Key attached to the underlying text field so tests can target it.
|
||||
final Key? inputKey;
|
||||
|
||||
final List<String> initialTags;
|
||||
|
||||
/// Field label rendered at the top of the editor with the restore action
|
||||
/// aligned to its end. Null renders no label row unless the restore action
|
||||
/// itself is visible.
|
||||
final String? labelText;
|
||||
|
||||
/// Shown inside the text field while there is neither a tag nor typed text.
|
||||
final String? placeholderText;
|
||||
|
||||
/// Shown inside the text field while there is no typed text but at least
|
||||
/// one tag, carrying the usage rules once the empty-state text no longer
|
||||
/// applies.
|
||||
final String? usagePlaceholderText;
|
||||
|
||||
/// Default extensions offered by the restore action while the editor is
|
||||
/// empty. Null or empty hides the action (e.g. custom categories).
|
||||
final List<String>? restoreDefaults;
|
||||
|
||||
/// Normalized extensions already owned by another category, mapped to
|
||||
/// that category's display name. Tokens in this map are rejected with an
|
||||
/// inline error naming the owner instead of being added to the tags.
|
||||
final Map<String, String>? takenByCategory;
|
||||
|
||||
/// Reports whether the editor currently holds an extension claimed by
|
||||
/// another category so the host can disable its submit action.
|
||||
final ValueChanged<bool>? onConflictChanged;
|
||||
|
||||
@override
|
||||
State<ExtensionTagEditor> createState() => ExtensionTagEditorState();
|
||||
}
|
||||
|
||||
class ExtensionTagEditorState extends State<ExtensionTagEditor> {
|
||||
late final List<String> _tags;
|
||||
late final TextEditingController _controller;
|
||||
late bool _lastConflictNotified;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tags = normalizeCategoryExtensions(widget.initialTags);
|
||||
_controller = TextEditingController();
|
||||
// The host seeds its own initial state from the same data, so only
|
||||
// subsequent changes need to be reported.
|
||||
_lastConflictNotified = _firstTakenConflict() != null;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Commits any pending input text to the tags and returns the full list.
|
||||
/// Extensions claimed by [takenByCategory] are left out.
|
||||
List<String> flush() {
|
||||
_update(_commitPending);
|
||||
final taken = widget.takenByCategory;
|
||||
return List<String>.unmodifiable(taken == null ? _tags : _tags.where((tag) => !taken.containsKey(tag)));
|
||||
}
|
||||
|
||||
void _update(VoidCallback fn) {
|
||||
setState(fn);
|
||||
final conflicted = _firstTakenConflict() != null;
|
||||
if (conflicted != _lastConflictNotified) {
|
||||
_lastConflictNotified = conflicted;
|
||||
widget.onConflictChanged?.call(conflicted);
|
||||
}
|
||||
}
|
||||
|
||||
void _commitPending() {
|
||||
final rejected = normalizeCategoryExtensions([
|
||||
_controller.text,
|
||||
]).where((token) => widget.takenByCategory?.containsKey(token) ?? false).toList(growable: false);
|
||||
_addTokens(_controller.text);
|
||||
if (rejected.isEmpty) {
|
||||
_controller.clear();
|
||||
} else {
|
||||
// Keep the rejected tokens in the input so the error stays visible
|
||||
// until the user edits them away.
|
||||
_controller.text = rejected.join(' ');
|
||||
_controller.selection = TextSelection.collapsed(offset: _controller.text.length);
|
||||
}
|
||||
}
|
||||
|
||||
void _addTokens(String raw) {
|
||||
for (final token in normalizeCategoryExtensions([raw])) {
|
||||
if (_tags.contains(token)) continue;
|
||||
if (widget.takenByCategory?.containsKey(token) ?? false) continue;
|
||||
_tags.add(token);
|
||||
}
|
||||
}
|
||||
|
||||
String? _firstTakenConflict() {
|
||||
final taken = widget.takenByCategory;
|
||||
if (taken == null || taken.isEmpty) return null;
|
||||
for (final token in normalizeCategoryExtensions([_controller.text])) {
|
||||
if (taken.containsKey(token)) return token;
|
||||
}
|
||||
for (final tag in _tags) {
|
||||
if (taken.containsKey(tag)) return tag;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _handleChanged(String value) {
|
||||
// Keep the placeholder and the duplicate highlight in sync while typing;
|
||||
// nothing is committed until the submit action.
|
||||
_update(() {});
|
||||
}
|
||||
|
||||
void _handleSubmitted(String value) {
|
||||
_update(_commitPending);
|
||||
}
|
||||
|
||||
void _removeTag(String tag) {
|
||||
_update(() => _tags.remove(tag));
|
||||
}
|
||||
|
||||
void _restoreDefaults() {
|
||||
_update(() {
|
||||
_tags
|
||||
..clear()
|
||||
..addAll(normalizeCategoryExtensions(widget.restoreDefaults ?? const []));
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = AppPalette.of(context);
|
||||
final showPlaceholder = widget.placeholderText != null && _tags.isEmpty && _controller.text.isEmpty;
|
||||
final showRestore = showPlaceholder && (widget.restoreDefaults?.isNotEmpty ?? false);
|
||||
String? placeholder;
|
||||
if (_controller.text.isEmpty) {
|
||||
if (showPlaceholder) {
|
||||
placeholder = widget.placeholderText;
|
||||
} else if (_tags.isNotEmpty && widget.usagePlaceholderText != null) {
|
||||
placeholder = widget.usagePlaceholderText;
|
||||
}
|
||||
}
|
||||
final pendingDuplicates = normalizeCategoryExtensions([_controller.text]).toSet();
|
||||
final conflict = _firstTakenConflict();
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (widget.labelText != null || showRestore) ...[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
if (widget.labelText != null)
|
||||
Text(widget.labelText!, style: TextStyle(color: palette.textSecondary, fontSize: 12)),
|
||||
if (showRestore) _buildRestoreButton(palette),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
if (_tags.isNotEmpty) ...[
|
||||
Wrap(
|
||||
spacing: AppDesignTokens.space4,
|
||||
runSpacing: AppDesignTokens.space4,
|
||||
children: [
|
||||
for (final tag in _tags)
|
||||
_buildChip(
|
||||
palette,
|
||||
tag,
|
||||
duplicate: pendingDuplicates.contains(tag) || (widget.takenByCategory?.containsKey(tag) ?? false),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
AppTextField(
|
||||
key: widget.inputKey,
|
||||
controller: _controller,
|
||||
placeholder: placeholder != null
|
||||
? Text(placeholder, style: TextStyle(fontSize: 12, color: palette.textMuted))
|
||||
: null,
|
||||
onChanged: _handleChanged,
|
||||
onSubmitted: _handleSubmitted,
|
||||
),
|
||||
if (conflict != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
context.l10n.categoryExtensionTaken(conflict, widget.takenByCategory![conflict]!),
|
||||
style: TextStyle(color: palette.error, fontSize: 12),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRestoreButton(AppPalette palette) {
|
||||
return shad.OutlineButton(
|
||||
key: const ValueKey('extension-restore-defaults'),
|
||||
onPressed: _restoreDefaults,
|
||||
size: shad.ButtonSize.small,
|
||||
density: shad.ButtonDensity.compact,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.restart_alt, size: 14, color: palette.brand),
|
||||
const SizedBox(width: 4),
|
||||
Text(context.l10n.categoryExtensionsRestoreDefaults, style: TextStyle(fontSize: 12, color: palette.brand)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChip(AppPalette palette, String tag, {required bool duplicate}) {
|
||||
return Container(
|
||||
key: ValueKey('extension-chip-$tag'),
|
||||
padding: const EdgeInsets.only(left: 8, right: 1),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: duplicate ? palette.error : palette.border),
|
||||
borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(tag, style: TextStyle(fontSize: 11, color: palette.textPrimary)),
|
||||
const SizedBox(width: 2),
|
||||
GestureDetector(
|
||||
key: ValueKey('extension-chip-remove-$tag'),
|
||||
onTap: () => _removeTag(tag),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: SizedBox(width: 32, height: 32, child: Icon(Icons.close, size: 13, color: palette.textSecondary)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user