mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-15 04:43: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
@@ -1,4 +1,5 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
|
||||
part 'downloader_config.g.dart';
|
||||
|
||||
@@ -17,12 +18,16 @@ class DownloaderConfig {
|
||||
ApiServerConfig api = ApiServerConfig();
|
||||
bool autoStartTasks;
|
||||
bool autoDeleteMissingFileTasks;
|
||||
List<DownloadCategory> categories;
|
||||
bool autoCategorize;
|
||||
|
||||
DownloaderConfig({
|
||||
this.downloadDir = '',
|
||||
this.maxRunning = 0,
|
||||
this.autoStartTasks = false,
|
||||
this.autoDeleteMissingFileTasks = false,
|
||||
this.categories = const [],
|
||||
this.autoCategorize = false,
|
||||
});
|
||||
|
||||
factory DownloaderConfig.fromJson(Map<String, dynamic> json) => _$DownloaderConfigFromJson(json);
|
||||
@@ -192,7 +197,6 @@ class ExtraConfig {
|
||||
bool runAsMenubarApp;
|
||||
bool analyticsEnabled;
|
||||
String analyticsClientId;
|
||||
List<DownloadCategory> downloadCategories;
|
||||
|
||||
ExtraConfig({
|
||||
this.themeMode = '',
|
||||
@@ -211,7 +215,6 @@ class ExtraConfig {
|
||||
this.runAsMenubarApp = false,
|
||||
this.analyticsEnabled = true,
|
||||
this.analyticsClientId = '',
|
||||
this.downloadCategories = const [],
|
||||
}) : windowState = windowState ?? WindowStateConfig();
|
||||
|
||||
factory ExtraConfig.fromJson(Map<String, dynamic>? json) =>
|
||||
@@ -241,6 +244,9 @@ class DownloadCategory {
|
||||
bool isBuiltIn;
|
||||
String? nameKey; // i18n key for built-in categories (e.g., 'categoryMusic')
|
||||
bool isDeleted; // Mark built-in categories as deleted instead of removing them
|
||||
// File extensions routed to this category, without leading dots, e.g. ['exe', 'msi'].
|
||||
// An empty list means the category never matches automatically.
|
||||
List<String> extensions;
|
||||
|
||||
DownloadCategory({
|
||||
required this.name,
|
||||
@@ -248,13 +254,93 @@ class DownloadCategory {
|
||||
this.isBuiltIn = false,
|
||||
this.nameKey,
|
||||
this.isDeleted = false,
|
||||
this.extensions = const [],
|
||||
});
|
||||
|
||||
factory DownloadCategory.fromJson(Map<String, dynamic> json) => _$DownloadCategoryFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$DownloadCategoryToJson(this);
|
||||
|
||||
/// The effective extension list. The result is always normalized: split on
|
||||
/// commas/whitespace, trimmed, lowercased, without leading dots and without
|
||||
/// duplicates; the original order is preserved. An empty list means the
|
||||
/// category never matches automatically.
|
||||
List<String> effectiveExtensions() => normalizeCategoryExtensions(extensions);
|
||||
}
|
||||
|
||||
/// Lowercases the ascii letters of [value] and leaves the rest alone. The
|
||||
/// backend lowercases the same way, because the unicode case tables of the two
|
||||
/// languages disagree for some code points and would let the two sides pick a
|
||||
/// different category for the same file.
|
||||
String asciiLower(String value) {
|
||||
final lowered = StringBuffer();
|
||||
for (final unit in value.codeUnits) {
|
||||
lowered.writeCharCode(unit >= 0x41 && unit <= 0x5A ? unit + 0x20 : unit);
|
||||
}
|
||||
return lowered.toString();
|
||||
}
|
||||
|
||||
/// Normalizes free form extension input into tokens: splits on commas and
|
||||
/// whitespace, trims, lowercases, strips leading dots, drops empties and
|
||||
/// deduplicates while keeping the original input order.
|
||||
List<String> normalizeCategoryExtensions(Iterable<String> raw) {
|
||||
final result = <String>{};
|
||||
for (final item in raw) {
|
||||
for (final part in item.split(RegExp(r'[,\s]+'))) {
|
||||
var extension = asciiLower(part.trim());
|
||||
while (extension.startsWith('.')) {
|
||||
extension = extension.substring(1);
|
||||
}
|
||||
if (extension.isNotEmpty) {
|
||||
result.add(extension);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList();
|
||||
}
|
||||
|
||||
/// Default extension lists of the built-in categories, keyed by the i18n
|
||||
/// nameKey. Used to seed the built-in categories on first launch and to offer
|
||||
/// the restore-defaults action in the category dialog.
|
||||
const kDefaultCategoryExtensions = <String, List<String>>{
|
||||
'categoryProgram': ['exe', 'msi', 'msix', 'apk', 'dmg', 'deb', 'rpm', 'pkg', 'appimage'],
|
||||
'categoryVideo': ['mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v', 'mpg', 'mpeg', '3gp'],
|
||||
'categoryMusic': ['mp3', 'flac', 'wav', 'aac', 'ogg', 'm4a', 'wma', 'ape'],
|
||||
'categoryDocument': ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'md', 'csv', 'epub', 'rtf'],
|
||||
};
|
||||
|
||||
/// Rebases built-in category paths that still live under [oldDir] onto
|
||||
/// [newDir] after the default download directory changed. Categories the
|
||||
/// user moved elsewhere, and user created categories, are left alone.
|
||||
void rebaseBuiltinCategoryPaths(DownloaderConfig config, {required String oldDir, required String newDir}) {
|
||||
if (oldDir.trim().isEmpty || newDir.trim().isEmpty) {
|
||||
return;
|
||||
}
|
||||
// The backend seeds the categories with the separator of its platform, which
|
||||
// the path package of the client does not always understand, so both sides
|
||||
// are compared in the forward slash form.
|
||||
final from = _forwardSlash(oldDir);
|
||||
final to = _forwardSlash(newDir);
|
||||
if (path.equals(from, to)) {
|
||||
return;
|
||||
}
|
||||
for (final category in config.categories) {
|
||||
if (!category.isBuiltIn || category.isDeleted || category.path.trim().isEmpty) {
|
||||
continue;
|
||||
}
|
||||
final current = _forwardSlash(category.path);
|
||||
if (path.equals(current, from)) {
|
||||
category.path = to;
|
||||
} else if (path.isWithin(from, current)) {
|
||||
category.path = path.join(to, path.relative(current, from: from));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rewrites the windows separator of [value] to the forward slash the path
|
||||
/// package uses.
|
||||
String _forwardSlash(String value) => value.replaceAll('\\', '/');
|
||||
|
||||
@JsonSerializable()
|
||||
class WebhookConfig {
|
||||
bool enable;
|
||||
|
||||
@@ -12,6 +12,12 @@ DownloaderConfig _$DownloaderConfigFromJson(Map<String, dynamic> json) =>
|
||||
maxRunning: (json['maxRunning'] as num?)?.toInt() ?? 0,
|
||||
autoStartTasks: json['autoStartTasks'] as bool? ?? false,
|
||||
autoDeleteMissingFileTasks: json['autoDeleteMissingFileTasks'] as bool? ?? false,
|
||||
categories:
|
||||
(json['categories'] as List<dynamic>?)
|
||||
?.map((e) => DownloadCategory.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const [],
|
||||
autoCategorize: json['autoCategorize'] as bool? ?? false,
|
||||
)
|
||||
..protocolConfig = ProtocolConfig.fromJson(json['protocolConfig'] as Map<String, dynamic>?)
|
||||
..extra = ExtraConfig.fromJson(json['extra'] as Map<String, dynamic>?)
|
||||
@@ -37,6 +43,8 @@ Map<String, dynamic> _$DownloaderConfigToJson(DownloaderConfig instance) => <Str
|
||||
'api': instance.api.toJson(),
|
||||
'autoStartTasks': instance.autoStartTasks,
|
||||
'autoDeleteMissingFileTasks': instance.autoDeleteMissingFileTasks,
|
||||
'categories': instance.categories.map((e) => e.toJson()).toList(),
|
||||
'autoCategorize': instance.autoCategorize,
|
||||
};
|
||||
|
||||
ApiServerConfig _$ApiServerConfigFromJson(Map<String, dynamic> json) => ApiServerConfig(
|
||||
@@ -186,11 +194,6 @@ ExtraConfig _$ExtraConfigFromJson(Map<String, dynamic> json) => ExtraConfig(
|
||||
runAsMenubarApp: json['runAsMenubarApp'] as bool? ?? false,
|
||||
analyticsEnabled: json['analyticsEnabled'] as bool? ?? true,
|
||||
analyticsClientId: json['analyticsClientId'] as String? ?? '',
|
||||
downloadCategories:
|
||||
(json['downloadCategories'] as List<dynamic>?)
|
||||
?.map((e) => DownloadCategory.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const [],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ExtraConfigToJson(ExtraConfig instance) => <String, dynamic>{
|
||||
@@ -210,7 +213,6 @@ Map<String, dynamic> _$ExtraConfigToJson(ExtraConfig instance) => <String, dynam
|
||||
'runAsMenubarApp': instance.runAsMenubarApp,
|
||||
'analyticsEnabled': instance.analyticsEnabled,
|
||||
'analyticsClientId': instance.analyticsClientId,
|
||||
'downloadCategories': instance.downloadCategories.map((e) => e.toJson()).toList(),
|
||||
};
|
||||
|
||||
WindowStateConfig _$WindowStateConfigFromJson(Map<String, dynamic> json) => WindowStateConfig(
|
||||
@@ -231,6 +233,7 @@ DownloadCategory _$DownloadCategoryFromJson(Map<String, dynamic> json) => Downlo
|
||||
isBuiltIn: json['isBuiltIn'] as bool? ?? false,
|
||||
nameKey: json['nameKey'] as String?,
|
||||
isDeleted: json['isDeleted'] as bool? ?? false,
|
||||
extensions: (json['extensions'] as List<dynamic>?)?.map((e) => e as String).toList() ?? const [],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$DownloadCategoryToJson(DownloadCategory instance) => <String, dynamic>{
|
||||
@@ -239,6 +242,7 @@ Map<String, dynamic> _$DownloadCategoryToJson(DownloadCategory instance) => <Str
|
||||
'isBuiltIn': instance.isBuiltIn,
|
||||
'nameKey': ?instance.nameKey,
|
||||
'isDeleted': instance.isDeleted,
|
||||
'extensions': instance.extensions,
|
||||
};
|
||||
|
||||
WebhookConfig _$WebhookConfigFromJson(Map<String, dynamic> json) => WebhookConfig(
|
||||
|
||||
@@ -9,6 +9,10 @@ class Options {
|
||||
String name;
|
||||
String path;
|
||||
bool asDefaultPath;
|
||||
|
||||
/// Keep the base download directory instead of routing the task to the
|
||||
/// directory of the category its resolved file belongs to.
|
||||
bool skipAutoCategorize;
|
||||
List<int> selectFiles;
|
||||
Object? extra;
|
||||
|
||||
@@ -17,6 +21,7 @@ class Options {
|
||||
this.name = '',
|
||||
this.path = '',
|
||||
this.asDefaultPath = false,
|
||||
this.skipAutoCategorize = false,
|
||||
this.selectFiles = const [],
|
||||
this.extra,
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ Options _$OptionsFromJson(Map<String, dynamic> json) => Options(
|
||||
name: json['name'] as String? ?? '',
|
||||
path: json['path'] as String? ?? '',
|
||||
asDefaultPath: json['asDefaultPath'] as bool? ?? false,
|
||||
skipAutoCategorize: json['skipAutoCategorize'] as bool? ?? false,
|
||||
selectFiles:
|
||||
(json['selectFiles'] as List<dynamic>?)
|
||||
?.map((e) => (e as num).toInt())
|
||||
@@ -24,6 +25,7 @@ Map<String, dynamic> _$OptionsToJson(Options instance) => <String, dynamic>{
|
||||
'name': instance.name,
|
||||
'path': instance.path,
|
||||
'asDefaultPath': instance.asDefaultPath,
|
||||
'skipAutoCategorize': instance.skipAutoCategorize,
|
||||
'selectFiles': instance.selectFiles,
|
||||
'extra': ?instance.extra,
|
||||
};
|
||||
|
||||
@@ -9,7 +9,12 @@ class ResolveResult {
|
||||
String id;
|
||||
Resource res;
|
||||
|
||||
ResolveResult({this.id = "", required this.res});
|
||||
/// The category directory the created task would be routed to. Empty when
|
||||
/// auto categorize does not apply. It is a hint for the ui, the create call
|
||||
/// decides the final path.
|
||||
String suggestedDownloadDir;
|
||||
|
||||
ResolveResult({this.id = "", required this.res, this.suggestedDownloadDir = ""});
|
||||
|
||||
factory ResolveResult.fromJson(Map<String, dynamic> json) => _$ResolveResultFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$ResolveResultToJson(this);
|
||||
|
||||
@@ -6,10 +6,14 @@ part of 'resolve_result.dart';
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
ResolveResult _$ResolveResultFromJson(Map<String, dynamic> json) =>
|
||||
ResolveResult(id: json['id'] as String? ?? "", res: Resource.fromJson(json['res'] as Map<String, dynamic>));
|
||||
ResolveResult _$ResolveResultFromJson(Map<String, dynamic> json) => ResolveResult(
|
||||
id: json['id'] as String? ?? "",
|
||||
res: Resource.fromJson(json['res'] as Map<String, dynamic>),
|
||||
suggestedDownloadDir: json['suggestedDownloadDir'] as String? ?? "",
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ResolveResultToJson(ResolveResult instance) => <String, dynamic>{
|
||||
'id': instance.id,
|
||||
'res': instance.res.toJson(),
|
||||
'suggestedDownloadDir': instance.suggestedDownloadDir,
|
||||
};
|
||||
@@ -1,11 +1,11 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../../api/api.dart' as api;
|
||||
import '../../api/model/downloader_config.dart';
|
||||
import 'downloader_config_init.dart';
|
||||
import '../../core/capabilities/app_capabilities.dart';
|
||||
import '../../core/common/api_server_state.dart';
|
||||
import '../../core/common/start_config.dart';
|
||||
@@ -242,60 +242,14 @@ class AppRuntimeController extends AsyncNotifier<AppRuntimeState> {
|
||||
return cfg;
|
||||
}
|
||||
|
||||
Future<DownloaderConfig> _loadDownloaderConfig() async {
|
||||
DownloaderConfig config;
|
||||
try {
|
||||
config = await ref.read(gopeedServiceProvider).getConfig();
|
||||
} catch (error, stackTrace) {
|
||||
logger.w('load downloader config failed', error, stackTrace);
|
||||
config = DownloaderConfig();
|
||||
}
|
||||
await _initDownloaderConfig(config);
|
||||
return config;
|
||||
}
|
||||
|
||||
Future<void> _initDownloaderConfig(DownloaderConfig config) async {
|
||||
final extra = config.extra;
|
||||
if (extra.themeMode.isEmpty) {
|
||||
extra.themeMode = 'system';
|
||||
}
|
||||
if (extra.themeColor.isEmpty) {
|
||||
extra.themeColor = 'green';
|
||||
}
|
||||
if (config.proxy.scheme.isEmpty) {
|
||||
config.proxy.scheme = 'http';
|
||||
}
|
||||
if (config.downloadDir.isEmpty) {
|
||||
config.downloadDir = await _defaultDownloadDir();
|
||||
}
|
||||
if (extra.downloadCategories.isEmpty) {
|
||||
extra.downloadCategories = [
|
||||
DownloadCategory(
|
||||
name: '',
|
||||
path: path.join(config.downloadDir, 'Music'),
|
||||
isBuiltIn: true,
|
||||
nameKey: 'categoryMusic',
|
||||
),
|
||||
DownloadCategory(
|
||||
name: '',
|
||||
path: path.join(config.downloadDir, 'Video'),
|
||||
isBuiltIn: true,
|
||||
nameKey: 'categoryVideo',
|
||||
),
|
||||
DownloadCategory(
|
||||
name: '',
|
||||
path: path.join(config.downloadDir, 'Document'),
|
||||
isBuiltIn: true,
|
||||
nameKey: 'categoryDocument',
|
||||
),
|
||||
DownloadCategory(
|
||||
name: '',
|
||||
path: path.join(config.downloadDir, 'Program'),
|
||||
isBuiltIn: true,
|
||||
nameKey: 'categoryProgram',
|
||||
),
|
||||
];
|
||||
}
|
||||
Future<DownloaderConfig> _loadDownloaderConfig() {
|
||||
return loadDownloaderConfig(
|
||||
load: () => ref.read(gopeedServiceProvider).getConfig(),
|
||||
save: (config) => ref.read(gopeedServiceProvider).putConfig(config),
|
||||
defaultDownloadDir: _defaultDownloadDir,
|
||||
onLoadError: (error, stackTrace) => logger.w('load downloader config failed', error, stackTrace),
|
||||
onSaveError: (error, stackTrace) => logger.w('persist downloader config failed', error, stackTrace),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _initAnalytics(DownloaderConfig config) async {
|
||||
@@ -322,11 +276,13 @@ class AppRuntimeController extends AsyncNotifier<AppRuntimeState> {
|
||||
}
|
||||
|
||||
Future<String> _defaultDownloadDir() async {
|
||||
// An unknown directory stays empty: the config is sent to the backend, and
|
||||
// a relative path would point at the backend working directory.
|
||||
if (kIsWeb) {
|
||||
return './';
|
||||
return '';
|
||||
}
|
||||
if (Util.isDesktop()) {
|
||||
return (await getDownloadsDirectory())?.path ?? './';
|
||||
return (await getDownloadsDirectory())?.path ?? '';
|
||||
}
|
||||
if (Util.isAndroid()) {
|
||||
return (await getExternalStorageDirectory())?.path ?? (await getApplicationDocumentsDirectory()).path;
|
||||
@@ -334,7 +290,7 @@ class AppRuntimeController extends AsyncNotifier<AppRuntimeState> {
|
||||
if (Util.isIOS()) {
|
||||
return (await getApplicationDocumentsDirectory()).path;
|
||||
}
|
||||
return './';
|
||||
return '';
|
||||
}
|
||||
|
||||
String _runningAddress(StartConfig config, int runningPort) {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'package:path/path.dart' as path;
|
||||
|
||||
import '../../api/model/downloader_config.dart';
|
||||
|
||||
/// Reads the downloader config, applies first-launch defaults and persists
|
||||
/// the result when the init changed it. Persisting is skipped when the
|
||||
/// initial load failed: writing defaults over an unreadable store would
|
||||
/// wipe the user's configuration.
|
||||
Future<DownloaderConfig> loadDownloaderConfig({
|
||||
required Future<DownloaderConfig> Function() load,
|
||||
required Future<void> Function(DownloaderConfig config) save,
|
||||
required Future<String> Function() defaultDownloadDir,
|
||||
void Function(Object error, StackTrace stackTrace)? onLoadError,
|
||||
void Function(Object error, StackTrace stackTrace)? onSaveError,
|
||||
}) async {
|
||||
DownloaderConfig config;
|
||||
var loaded = true;
|
||||
try {
|
||||
config = await load();
|
||||
} catch (error, stackTrace) {
|
||||
onLoadError?.call(error, stackTrace);
|
||||
config = DownloaderConfig();
|
||||
loaded = false;
|
||||
}
|
||||
final changed = await initDownloaderConfig(config, defaultDownloadDir: defaultDownloadDir);
|
||||
if (loaded && changed) {
|
||||
// Persist the seeded categories so the backend can route
|
||||
// downloads to them right away instead of waiting for the settings page
|
||||
// to save the config.
|
||||
try {
|
||||
await save(config);
|
||||
} catch (error, stackTrace) {
|
||||
onSaveError?.call(error, stackTrace);
|
||||
}
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
/// Applies defaults to a freshly loaded config: theme/tracker/proxy basics,
|
||||
/// the default download directory, and seeding the built-in categories when
|
||||
/// nothing exists yet. Returns whether the config changed and should be
|
||||
/// persisted.
|
||||
Future<bool> initDownloaderConfig(
|
||||
DownloaderConfig config, {
|
||||
required Future<String> Function() defaultDownloadDir,
|
||||
}) async {
|
||||
var changed = false;
|
||||
final extra = config.extra;
|
||||
if (extra.themeMode.isEmpty) {
|
||||
extra.themeMode = 'system';
|
||||
}
|
||||
if (extra.themeColor.isEmpty) {
|
||||
extra.themeColor = 'green';
|
||||
}
|
||||
if (config.proxy.scheme.isEmpty) {
|
||||
config.proxy.scheme = 'http';
|
||||
}
|
||||
if (config.downloadDir.isEmpty) {
|
||||
final downloadDir = await defaultDownloadDir();
|
||||
// An unknown directory stays empty, the backend resolves its own.
|
||||
if (downloadDir.isNotEmpty) {
|
||||
config.downloadDir = downloadDir;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (config.categories.isEmpty && config.downloadDir.isNotEmpty) {
|
||||
config.categories = [
|
||||
_builtinCategory('categoryMusic', path.join(config.downloadDir, 'Music')),
|
||||
_builtinCategory('categoryVideo', path.join(config.downloadDir, 'Video')),
|
||||
_builtinCategory('categoryDocument', path.join(config.downloadDir, 'Document')),
|
||||
_builtinCategory('categoryProgram', path.join(config.downloadDir, 'Program')),
|
||||
];
|
||||
changed = true;
|
||||
}
|
||||
if (config.githubMirror.mirrors.isEmpty) {
|
||||
config.githubMirror.mirrors = [
|
||||
GithubMirror(type: GithubMirrorType.jsdelivr, url: 'https://fastly.jsdelivr.net/gh', isBuiltIn: true),
|
||||
GithubMirror(type: GithubMirrorType.ghProxy, url: 'https://fastgit.cc', isBuiltIn: true),
|
||||
];
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
DownloadCategory _builtinCategory(String nameKey, String categoryPath) {
|
||||
return DownloadCategory(
|
||||
name: '',
|
||||
path: categoryPath,
|
||||
isBuiltIn: true,
|
||||
nameKey: nameKey,
|
||||
extensions: List<String>.from(kDefaultCategoryExtensions[nameKey] ?? const []),
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart' show Icons;
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
@@ -88,6 +89,7 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||
DownloaderConfig? _config;
|
||||
StartConfig? _startConfig;
|
||||
String? _loadedSignature;
|
||||
String? _appliedDownloadDir;
|
||||
String? _loadedStartSignature;
|
||||
Timer? _textSaveTimer;
|
||||
bool _syncingControllers = false;
|
||||
@@ -470,13 +472,21 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||
SettingsItem(
|
||||
title: context.l10n.downloadCategories,
|
||||
child: DownloadCategoriesControl(
|
||||
categories: config.extra.downloadCategories,
|
||||
categories: config.categories,
|
||||
displayName: _categoryName,
|
||||
onAdd: () => unawaited(_editDownloadCategory()),
|
||||
onEdit: (category) => unawaited(_editDownloadCategory(category)),
|
||||
onDelete: (category) => unawaited(_deleteDownloadCategory(category)),
|
||||
),
|
||||
),
|
||||
SettingsItem(
|
||||
title: context.l10n.autoCategorizeByExtension,
|
||||
subtitle: context.l10n.autoCategorizeByExtensionDescription,
|
||||
child: shad.Switch(
|
||||
value: config.autoCategorize,
|
||||
onChanged: (value) => _mutateConfig((next) => next.autoCategorize = value),
|
||||
),
|
||||
),
|
||||
SettingsItem(
|
||||
title: context.l10n.maxRunning,
|
||||
subtitle: context.l10n.maxRunningDescription,
|
||||
@@ -996,6 +1006,7 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||
_config = DownloaderConfig.fromJson(config.toJson());
|
||||
_syncingControllers = true;
|
||||
_downloadDirController.text = _config!.downloadDir;
|
||||
_appliedDownloadDir = _config!.downloadDir;
|
||||
_maxRunningController.text = _config!.maxRunning.clamp(1, 256).toString();
|
||||
_httpUserAgentController.text = _config!.protocolConfig.http.userAgent;
|
||||
_httpConnectionsController.text = _config!.protocolConfig.http.connections.clamp(1, 256).toString();
|
||||
@@ -1087,7 +1098,13 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||
}
|
||||
|
||||
void _applyTextControllers(DownloaderConfig config) {
|
||||
config.downloadDir = _downloadDirController.text.trim();
|
||||
final downloadDir = _downloadDirController.text.trim();
|
||||
final previousDownloadDir = _appliedDownloadDir;
|
||||
_appliedDownloadDir = downloadDir;
|
||||
if (previousDownloadDir != null && !path.equals(previousDownloadDir, downloadDir)) {
|
||||
rebaseBuiltinCategoryPaths(config, oldDir: previousDownloadDir, newDir: downloadDir);
|
||||
}
|
||||
config.downloadDir = downloadDir;
|
||||
config.maxRunning = _boundedInt(_maxRunningController, fallback: config.maxRunning, min: 1, max: 256);
|
||||
config.protocolConfig.http.userAgent = _httpUserAgentController.text.trim();
|
||||
config.protocolConfig.http.connections = _boundedInt(
|
||||
@@ -1357,13 +1374,15 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||
category: category,
|
||||
initialName: category == null ? '' : _categoryName(category),
|
||||
initialPath: _downloadDirController.text.trim(),
|
||||
categories: _config?.categories ?? const [],
|
||||
displayName: _categoryName,
|
||||
);
|
||||
if (draft == null || !mounted) return;
|
||||
|
||||
_mutateConfig((config) {
|
||||
final categories = List<DownloadCategory>.from(config.extra.downloadCategories);
|
||||
final categories = List<DownloadCategory>.from(config.categories);
|
||||
if (category == null) {
|
||||
categories.add(DownloadCategory(name: draft.name, path: draft.path));
|
||||
categories.add(DownloadCategory(name: draft.name, path: draft.path, extensions: draft.extensions));
|
||||
} else {
|
||||
final index = categories.indexOf(category);
|
||||
if (index < 0) return;
|
||||
@@ -1371,10 +1390,11 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||
category
|
||||
..name = draft.name
|
||||
..path = draft.path
|
||||
..extensions = draft.extensions
|
||||
..isDeleted = false;
|
||||
if (nameChanged) category.nameKey = null;
|
||||
}
|
||||
config.extra.downloadCategories = categories;
|
||||
config.categories = categories;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1386,7 +1406,7 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||
category.isDeleted = true;
|
||||
return;
|
||||
}
|
||||
config.extra.downloadCategories = List<DownloadCategory>.from(config.extra.downloadCategories)..remove(category);
|
||||
config.categories = List<DownloadCategory>.from(config.categories)..remove(category);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,27 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
bool _draggingTorrent = false;
|
||||
bool _asDefaultPath = false;
|
||||
String _configuredDownloadDirectory = '';
|
||||
|
||||
/// Index into `_downloadCategories` as filtered by `_loadDefaults`
|
||||
/// (`!isDeleted`), not into the raw config list; null when the directory
|
||||
/// falls outside every category. Cached, so it can lag the field while the
|
||||
/// debounce is pending — `onShortcutSelected` reads exactly this value.
|
||||
int? _activeCategoryIndex;
|
||||
Timer? _categoryHighlightTimer;
|
||||
|
||||
/// Whether the user picked a directory in this dialog. The backend routes a
|
||||
/// task to its category directory only when this stays false, so the flag is
|
||||
/// tracked on its own instead of comparing the field with the default dir.
|
||||
bool _directoryManuallySet = false;
|
||||
|
||||
/// Guards the controller listener while the page fills the field itself.
|
||||
bool _programmaticDirectoryWrite = false;
|
||||
|
||||
/// The text of the directory field the page knows about. The controller also
|
||||
/// reports selection changes, e.g. when the caret moves, so the listener
|
||||
/// compares the text to tell a user edit from a caret move.
|
||||
String _lastDirectoryText = '';
|
||||
|
||||
String _fileDataUri = '';
|
||||
bool _programmaticUrlChange = false;
|
||||
String _lastUrlText = '';
|
||||
@@ -100,6 +121,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_urlController.addListener(_handleUrlChanged);
|
||||
_directoryController.addListener(_handleDirectoryChanged);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final task = widget.initialTask ?? ref.read(pendingCreateTaskProvider);
|
||||
@@ -127,9 +149,12 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
void dispose() {
|
||||
_urlController.removeListener(_handleUrlChanged);
|
||||
_urlController.dispose();
|
||||
_directoryController.removeListener(_handleDirectoryChanged);
|
||||
_categoryHighlightTimer?.cancel();
|
||||
_categoryHighlightTimer = null;
|
||||
_renameController.dispose();
|
||||
_connectionsController.dispose();
|
||||
_directoryController.dispose();
|
||||
_connectionsController.dispose();
|
||||
_proxyServerController.dispose();
|
||||
_proxyPortController.dispose();
|
||||
_proxyUsernameController.dispose();
|
||||
@@ -200,7 +225,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
void _applyInitialTask(CreateTask? task) {
|
||||
if (task == null || !mounted) return;
|
||||
final req = task.req;
|
||||
final opts = task.opts;
|
||||
final opts = task.opts ?? api_options.Options();
|
||||
setState(() {
|
||||
_initialSize = 0;
|
||||
_checksumAlgorithm = 'md5';
|
||||
@@ -241,30 +266,35 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (opts != null) {
|
||||
_initialSize = opts.size;
|
||||
if (opts.path.isNotEmpty) {
|
||||
_directoryController.text = opts.path;
|
||||
// Filling the field is not a manual choice, the caller decides whether
|
||||
// the task keeps its directory.
|
||||
_directoryManuallySet = opts.skipAutoCategorize;
|
||||
_asDefaultPath = opts.skipAutoCategorize && opts.asDefaultPath;
|
||||
_programmaticDirectoryWrite = true;
|
||||
// A task without a directory uses the configured one, the field must
|
||||
// not keep the directory of the previous task.
|
||||
_directoryController.text = opts.path.isNotEmpty ? opts.path : _configuredDownloadDirectory;
|
||||
_programmaticDirectoryWrite = false;
|
||||
_initialSize = opts.size;
|
||||
if (opts.extra is Map) {
|
||||
final extra = api_options.OptsExtraHttp.fromJson(Map<String, dynamic>.from(opts.extra! as Map));
|
||||
if (extra.connections > 0) {
|
||||
_connectionsController.text = extra.connections.toString();
|
||||
}
|
||||
if (opts.extra is Map) {
|
||||
final extra = api_options.OptsExtraHttp.fromJson(Map<String, dynamic>.from(opts.extra! as Map));
|
||||
if (extra.connections > 0) {
|
||||
_connectionsController.text = extra.connections.toString();
|
||||
}
|
||||
_autoTorrent = extra.autoTorrent;
|
||||
_deleteTorrentAfterDownload = extra.deleteTorrentAfterDownload;
|
||||
_autoExtract = extra.autoExtract;
|
||||
_archivePasswordController.text = extra.archivePassword;
|
||||
_deleteAfterExtract = extra.deleteAfterExtract;
|
||||
if (extra.checksum != null) {
|
||||
if (extra.checksum!.algorithm.isNotEmpty) {
|
||||
_checksumAlgorithm = extra.checksum!.algorithm;
|
||||
}
|
||||
_checksumHashController.text = extra.checksum!.expected;
|
||||
_autoTorrent = extra.autoTorrent;
|
||||
_deleteTorrentAfterDownload = extra.deleteTorrentAfterDownload;
|
||||
_autoExtract = extra.autoExtract;
|
||||
_archivePasswordController.text = extra.archivePassword;
|
||||
_deleteAfterExtract = extra.deleteAfterExtract;
|
||||
if (extra.checksum != null) {
|
||||
if (extra.checksum!.algorithm.isNotEmpty) {
|
||||
_checksumAlgorithm = extra.checksum!.algorithm;
|
||||
}
|
||||
_checksumHashController.text = extra.checksum!.expected;
|
||||
}
|
||||
}
|
||||
});
|
||||
_applyCategoryHighlightNow();
|
||||
}
|
||||
|
||||
void _applyInitialProxy(RequestProxy? proxy) {
|
||||
@@ -429,15 +459,21 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
setState(() => _asDefaultPath = false);
|
||||
}
|
||||
},
|
||||
onDirectoryPicked: (pickedDirectory) => setState(
|
||||
() => _asDefaultPath = !_sameDirectory(pickedDirectory, _configuredDownloadDirectory),
|
||||
),
|
||||
onDirectoryPicked: (pickedDirectory) {
|
||||
setState(() {
|
||||
_directoryManuallySet = true;
|
||||
_asDefaultPath = !_sameDirectory(pickedDirectory, _configuredDownloadDirectory);
|
||||
});
|
||||
_applyCategoryHighlightNow();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_DirectoryOptions(
|
||||
key: const ValueKey('create-task-directory-options-row'),
|
||||
stacked: stacked,
|
||||
asDefaultPath: _asDefaultPath,
|
||||
canSetAsDefaultPath: _directoryManuallySet,
|
||||
activeIndex: _activeCategoryIndex,
|
||||
onAsDefaultPathChanged: (value) => setState(() => _asDefaultPath = value),
|
||||
shortcuts: [
|
||||
for (final entry in _downloadCategories.indexed)
|
||||
@@ -448,16 +484,22 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
),
|
||||
],
|
||||
onShortcutSelected: (shortcut) {
|
||||
_directoryController.text = shortcut.path;
|
||||
if (_asDefaultPath) {
|
||||
setState(() => _asDefaultPath = false);
|
||||
}
|
||||
// Tapping the active shortcut again clears the selection and
|
||||
// restores the saved default directory. Either tap is an
|
||||
// explicit directory choice, so the task skips auto classify.
|
||||
final turnOn = _activeCategoryIndex != shortcut.index;
|
||||
setState(() {
|
||||
_directoryManuallySet = true;
|
||||
_asDefaultPath = false;
|
||||
});
|
||||
_directoryController.text = turnOn ? shortcut.path : _configuredDownloadDirectory;
|
||||
_applyCategoryHighlightNow();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
const SizedBox(height: 8),
|
||||
AppFormRow(
|
||||
direction: stacked ? Axis.vertical : Axis.horizontal,
|
||||
label: context.l10n.directDownload,
|
||||
@@ -808,7 +850,9 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
_configuredDownloadDirectory = config.downloadDir.trim();
|
||||
if (config.downloadDir.isNotEmpty &&
|
||||
(_directoryController.text.isEmpty || _directoryController.text == 'C:/Users/levi/Downloads')) {
|
||||
_programmaticDirectoryWrite = true;
|
||||
_directoryController.text = config.downloadDir;
|
||||
_programmaticDirectoryWrite = false;
|
||||
}
|
||||
if (_asDefaultPath && _sameDirectory(_directoryController.text, _configuredDownloadDirectory)) {
|
||||
_asDefaultPath = false;
|
||||
@@ -818,10 +862,9 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
_connectionsController.text = connections.toString();
|
||||
}
|
||||
_directDownload = config.extra.defaultDirectDownload;
|
||||
_downloadCategories = config.extra.downloadCategories
|
||||
.where((category) => !category.isDeleted)
|
||||
.toList(growable: false);
|
||||
_downloadCategories = config.categories.where((category) => !category.isDeleted).toList(growable: false);
|
||||
});
|
||||
_applyCategoryHighlightNow();
|
||||
} catch (_) {
|
||||
// Keep local defaults when the backend is not available yet.
|
||||
}
|
||||
@@ -1087,7 +1130,12 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
return text.length == 40 && RegExp(r'^[0-9a-fA-F]+$').hasMatch(text);
|
||||
}
|
||||
|
||||
api_options.Options _buildOptions({String? name, List<int> selectFiles = const [], bool omitChecksum = false}) {
|
||||
api_options.Options _buildOptions({
|
||||
String? name,
|
||||
List<int> selectFiles = const [],
|
||||
bool omitChecksum = false,
|
||||
bool? skipAutoCategorize,
|
||||
}) {
|
||||
final urls = _inputUrls();
|
||||
final connections = int.tryParse(_connectionsController.text.trim()) ?? 0;
|
||||
final checksumExpected = _checksumHashController.text.trim();
|
||||
@@ -1099,6 +1147,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
name: name ?? _renameController.text.trim(),
|
||||
path: _directoryController.text.trim(),
|
||||
asDefaultPath: _asDefaultPath,
|
||||
skipAutoCategorize: skipAutoCategorize ?? _directoryManuallySet,
|
||||
selectFiles: selectFiles,
|
||||
extra: api_options.OptsExtraHttp(
|
||||
connections: connections,
|
||||
@@ -1119,6 +1168,60 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
return path.equals(path.normalize(normalizedLeft), path.normalize(normalizedRight));
|
||||
}
|
||||
|
||||
/// Index of the category whose path matches [value], or null. Category paths
|
||||
/// are resolved through `_renderPathPlaceholders` before comparing, while
|
||||
/// [value] is taken verbatim, so a `%year%` pattern only matches once the
|
||||
/// directory field already holds the rendered path.
|
||||
int? _categoryIndexFor(String value) {
|
||||
final target = value.trim();
|
||||
if (target.isEmpty) return null;
|
||||
for (final entry in _downloadCategories.indexed) {
|
||||
if (_sameDirectory(_renderPathPlaceholders(entry.$2.path), target)) return entry.$1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _applyCategoryHighlight() {
|
||||
final next = _categoryIndexFor(_directoryController.text);
|
||||
if (next == _activeCategoryIndex) return;
|
||||
setState(() => _activeCategoryIndex = next);
|
||||
}
|
||||
|
||||
/// Debounced variant for paths typed into the directory field, so the
|
||||
/// highlight does not flicker while the path is still incomplete.
|
||||
/// A text change the user typed is an explicit directory choice; one the
|
||||
/// page writes itself (initial fill, incoming task) is not. The controller
|
||||
/// also notifies on selection changes, e.g. when the caret moves, so only a
|
||||
/// changed text counts as a manual choice.
|
||||
void _handleDirectoryChanged() {
|
||||
final text = _directoryController.text;
|
||||
if (text == _lastDirectoryText) {
|
||||
return;
|
||||
}
|
||||
_lastDirectoryText = text;
|
||||
if (!_programmaticDirectoryWrite && !_directoryManuallySet) {
|
||||
setState(() => _directoryManuallySet = true);
|
||||
}
|
||||
_scheduleCategoryHighlight();
|
||||
}
|
||||
|
||||
void _scheduleCategoryHighlight() {
|
||||
_categoryHighlightTimer?.cancel();
|
||||
_categoryHighlightTimer = Timer(_categoryHighlightDebounce, () {
|
||||
if (!mounted) return;
|
||||
_applyCategoryHighlight();
|
||||
});
|
||||
}
|
||||
|
||||
/// Immediate variant for shortcut clicks and programmatic writes, which
|
||||
/// cancels a pending debounce instead of waiting for it.
|
||||
void _applyCategoryHighlightNow() {
|
||||
_categoryHighlightTimer?.cancel();
|
||||
_categoryHighlightTimer = null;
|
||||
if (!mounted) return;
|
||||
_applyCategoryHighlight();
|
||||
}
|
||||
|
||||
Future<void> _submitResolved(Request request, ResolveResult result, List<int> selectedIndexes) async {
|
||||
final selected = (selectedIndexes.isEmpty ? result.res.files.asMap().keys.toList() : selectedIndexes.toList())
|
||||
..sort();
|
||||
@@ -1136,6 +1239,11 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
}
|
||||
|
||||
if (result.res.files.isNotEmpty) {
|
||||
// The extension returned a file list, so the resource keeps a directory
|
||||
// structure. The split tasks must not be classified into a category, the
|
||||
// structure of the resolved resource is kept. The decision is based on
|
||||
// the resolved resource, not on the number of selected files.
|
||||
final keepStructure = _directoryManuallySet || result.res.name.isNotEmpty || result.res.files.length > 1;
|
||||
final reqs = selected.map((index) {
|
||||
final file = result.res.files[index];
|
||||
final fileRequest = file.req ?? request;
|
||||
@@ -1145,6 +1253,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
size: _parseProtocol(fileRequest.url) == _TaskProtocol.http ? file.size : 0,
|
||||
name: file.name,
|
||||
path: path.join(_directoryController.text.trim(), result.res.name, file.path),
|
||||
skipAutoCategorize: keepStructure,
|
||||
extra: _buildOptions(omitChecksum: isMultiFile).extra,
|
||||
),
|
||||
);
|
||||
@@ -1154,7 +1263,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
.createTaskBatch(
|
||||
CreateTaskBatch(
|
||||
reqs: reqs,
|
||||
opts: _buildOptions(omitChecksum: isMultiFile),
|
||||
opts: _buildOptions(omitChecksum: isMultiFile, skipAutoCategorize: keepStructure),
|
||||
),
|
||||
);
|
||||
return;
|
||||
@@ -1168,6 +1277,12 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
var selectedIndexes = List<int>.of(initialSelection);
|
||||
var submitting = false;
|
||||
String? errorText;
|
||||
// The directory the task will be saved to. A directory the user named
|
||||
// wins over the category suggestion of the resolve response, the backend
|
||||
// keeps it and does not classify the task.
|
||||
final effectiveDownloadDir = _directoryManuallySet
|
||||
? _directoryController.text.trim()
|
||||
: (result.suggestedDownloadDir.isNotEmpty ? result.suggestedDownloadDir : _directoryController.text.trim());
|
||||
|
||||
final dialog = const DialogOverlayHandler().show<bool>(
|
||||
context: context,
|
||||
@@ -1223,6 +1338,16 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
: dialogContext.l10n.resolvedFileCount(result.res.files.length),
|
||||
style: TextStyle(color: palette.textSecondary, fontSize: 12),
|
||||
),
|
||||
if (effectiveDownloadDir.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
dialogContext.l10n.resolvedCategoryDirectory(effectiveDownloadDir),
|
||||
key: const ValueKey('resolve-category-directory'),
|
||||
style: TextStyle(color: palette.textSecondary, fontSize: 12),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
height: treeHeight.clamp(260.0, 460.0),
|
||||
@@ -1512,9 +1637,10 @@ class _DirectDownloadToggle extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _AsDefaultPathToggle extends StatelessWidget {
|
||||
const _AsDefaultPathToggle({required this.value, required this.onChanged});
|
||||
const _AsDefaultPathToggle({required this.value, required this.enabled, required this.onChanged});
|
||||
|
||||
final bool value;
|
||||
final bool enabled;
|
||||
final ValueChanged<bool> onChanged;
|
||||
|
||||
@override
|
||||
@@ -1523,7 +1649,7 @@ class _AsDefaultPathToggle extends StatelessWidget {
|
||||
return GestureDetector(
|
||||
key: const ValueKey('create-task-remember-download-directory'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => onChanged(!value),
|
||||
onTap: enabled ? () => onChanged(!value) : null,
|
||||
child: SizedBox(
|
||||
height: 24,
|
||||
child: Row(
|
||||
@@ -1532,7 +1658,7 @@ class _AsDefaultPathToggle extends StatelessWidget {
|
||||
Checkbox(
|
||||
key: const ValueKey('create-task-remember-download-directory-checkbox'),
|
||||
state: value ? CheckboxState.checked : CheckboxState.unchecked,
|
||||
onChanged: (_) => onChanged(!value),
|
||||
onChanged: enabled ? (_) => onChanged(!value) : null,
|
||||
),
|
||||
const SizedBox(width: AppDesignTokens.checkboxLabelGap),
|
||||
Flexible(
|
||||
@@ -1564,6 +1690,8 @@ class _DirectoryOptions extends StatelessWidget {
|
||||
super.key,
|
||||
required this.stacked,
|
||||
required this.asDefaultPath,
|
||||
required this.canSetAsDefaultPath,
|
||||
required this.activeIndex,
|
||||
required this.onAsDefaultPathChanged,
|
||||
required this.shortcuts,
|
||||
required this.onShortcutSelected,
|
||||
@@ -1571,14 +1699,28 @@ class _DirectoryOptions extends StatelessWidget {
|
||||
|
||||
final bool stacked;
|
||||
final bool asDefaultPath;
|
||||
|
||||
/// The global default download directory is only saved for a directory the
|
||||
/// user named explicitly, so the toggle stays disabled until then.
|
||||
final bool canSetAsDefaultPath;
|
||||
final int? activeIndex;
|
||||
final ValueChanged<bool> onAsDefaultPathChanged;
|
||||
final List<_DirectoryShortcut> shortcuts;
|
||||
final ValueChanged<_DirectoryShortcut> onShortcutSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final toggle = _AsDefaultPathToggle(value: asDefaultPath, onChanged: onAsDefaultPathChanged);
|
||||
final shortcutList = _DirectoryShortcuts(shortcuts: shortcuts, alignEnd: !stacked, onSelected: onShortcutSelected);
|
||||
final toggle = _AsDefaultPathToggle(
|
||||
value: asDefaultPath,
|
||||
enabled: canSetAsDefaultPath,
|
||||
onChanged: onAsDefaultPathChanged,
|
||||
);
|
||||
final shortcutList = _DirectoryShortcuts(
|
||||
shortcuts: shortcuts,
|
||||
activeIndex: activeIndex,
|
||||
alignEnd: !stacked,
|
||||
onSelected: onShortcutSelected,
|
||||
);
|
||||
|
||||
if (stacked) {
|
||||
return Column(
|
||||
@@ -1613,9 +1755,15 @@ class _DirectoryOptions extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _DirectoryShortcuts extends StatelessWidget {
|
||||
const _DirectoryShortcuts({required this.shortcuts, required this.alignEnd, required this.onSelected});
|
||||
const _DirectoryShortcuts({
|
||||
required this.shortcuts,
|
||||
required this.activeIndex,
|
||||
required this.alignEnd,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
final List<_DirectoryShortcut> shortcuts;
|
||||
final int? activeIndex;
|
||||
final bool alignEnd;
|
||||
final ValueChanged<_DirectoryShortcut> onSelected;
|
||||
|
||||
@@ -1631,6 +1779,7 @@ class _DirectoryShortcuts extends StatelessWidget {
|
||||
_DirectoryShortcutButton(
|
||||
key: ValueKey('create-task-category-${shortcut.index}'),
|
||||
shortcut: shortcut,
|
||||
active: shortcut.index == activeIndex,
|
||||
onPressed: () => onSelected(shortcut),
|
||||
),
|
||||
],
|
||||
@@ -1651,6 +1800,7 @@ class _DirectoryShortcuts extends StatelessWidget {
|
||||
_DirectoryShortcutButton(
|
||||
key: ValueKey('create-task-category-${shortcut.index}'),
|
||||
shortcut: shortcut,
|
||||
active: shortcut.index == activeIndex,
|
||||
onPressed: () => onSelected(shortcut),
|
||||
),
|
||||
],
|
||||
@@ -1662,9 +1812,10 @@ class _DirectoryShortcuts extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _DirectoryShortcutButton extends StatefulWidget {
|
||||
const _DirectoryShortcutButton({super.key, required this.shortcut, required this.onPressed});
|
||||
const _DirectoryShortcutButton({super.key, required this.shortcut, required this.active, required this.onPressed});
|
||||
|
||||
final _DirectoryShortcut shortcut;
|
||||
final bool active;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
@@ -1677,10 +1828,12 @@ class _DirectoryShortcutButtonState extends State<_DirectoryShortcutButton> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = AppPalette.of(context);
|
||||
final active = widget.active;
|
||||
return AppTooltip(
|
||||
message: widget.shortcut.path,
|
||||
child: Semantics(
|
||||
button: true,
|
||||
selected: active,
|
||||
label: widget.shortcut.label,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
@@ -1696,21 +1849,25 @@ class _DirectoryShortcutButtonState extends State<_DirectoryShortcutButton> {
|
||||
constraints: const BoxConstraints(maxWidth: 168),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 9),
|
||||
decoration: BoxDecoration(
|
||||
color: _hovered ? palette.surfaceSoft : palette.cardBg,
|
||||
color: active ? palette.brandSoft : (_hovered ? palette.surfaceSoft : palette.cardBg),
|
||||
borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius),
|
||||
border: Border.all(color: palette.border),
|
||||
border: Border.all(color: active ? palette.brand.withValues(alpha: 0.45) : palette.border),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.folder_outlined, size: 14, color: palette.textMuted),
|
||||
Icon(Icons.folder_outlined, size: 14, color: active ? palette.brand : palette.textMuted),
|
||||
const SizedBox(width: 5),
|
||||
Flexible(
|
||||
child: Text(
|
||||
widget.shortcut.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: palette.textSecondary, fontSize: 11.5, fontWeight: FontWeight.w600),
|
||||
style: TextStyle(
|
||||
color: active ? palette.textPrimary : palette.textSecondary,
|
||||
fontSize: 11.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -1842,6 +1999,8 @@ class _SegmentButton extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
const _categoryHighlightDebounce = Duration(milliseconds: 300);
|
||||
|
||||
const _advancedExpandDuration = Duration(milliseconds: 220);
|
||||
const _advancedScrollDelay = Duration(milliseconds: 90);
|
||||
const _advancedScrollDuration = Duration(milliseconds: 260);
|
||||
|
||||
@@ -203,6 +203,13 @@
|
||||
"categoryProgram": "برامج",
|
||||
"categoryName": "اسم الفئة",
|
||||
"categoryPath": "مسار الفئة",
|
||||
"autoCategorizeByExtension": "تصنيف تلقائي حسب نوع الملف",
|
||||
"autoCategorizeByExtensionDescription": "توجيه التنزيلات إلى مجلد الفئة تلقائيًا حسب امتداد الملف عند الحفظ في مجلد التنزيل الافتراضي.",
|
||||
"categoryExtensions": "امتدادات الملفات",
|
||||
"categoryExtensionsUsageHint": "افصل الامتدادات بفواصل أو مسافات ثم اضغط Enter.",
|
||||
"categoryExtensionsEmptyHint": "بدون الامتدادات، لن تُحفظ الملفات في هذا التصنيف تلقائيًا.",
|
||||
"categoryExtensionsRestoreDefaults": "استعادة الامتدادات الافتراضية",
|
||||
"categoryExtensionTaken": "{extension} مستخدم بالفعل بواسطة {category}",
|
||||
"builtInCategory": "لا يمكن حذف الفئة المضمنة",
|
||||
"selectCategory": "اختر الفئة",
|
||||
"quickDirectories": "مجلدات سريعة",
|
||||
@@ -363,6 +370,7 @@
|
||||
"selectAtLeastOneFile": "اختار ملف واحد على الأقل",
|
||||
"resolvedFiles": "الملفات المبتغاة",
|
||||
"resolvedFileCount": "{count}",
|
||||
"resolvedCategoryDirectory": "سيتم الحفظ في {path}",
|
||||
"resourceHasNoFiles": "ولا توجد في هذا المورد قائمة ملف قابلة للاختيار",
|
||||
"noFiles": "لا توجد ملفات",
|
||||
"historyLinks": "روابط السجل",
|
||||
|
||||
@@ -210,6 +210,13 @@
|
||||
"categoryProgram": "Proqram",
|
||||
"categoryName": "Kateqoriyanın Adı",
|
||||
"categoryPath": "Kateqoriyanın Yolu",
|
||||
"autoCategorizeByExtension": "Fayl tipinə görə avtomatik kateqoriyalaşdırma",
|
||||
"autoCategorizeByExtensionDescription": "Standart yükləmə qovluğuna yadda saxlanarkən yükləmələr fayl uzantısına görə avtomatik olaraq kateqoriya qovluğuna yönləndirilir.",
|
||||
"categoryExtensions": "Fayl uzantıları",
|
||||
"categoryExtensionsUsageHint": "Uzantıları vergül və ya boşluqla ayırın, sonra Enter-a basın.",
|
||||
"categoryExtensionsEmptyHint": "Uzantı yoxdursa, fayllar bu kateqoriyaya avtomatik olaraq saxlanmır.",
|
||||
"categoryExtensionsRestoreDefaults": "Standart uzantıları bərpa et",
|
||||
"categoryExtensionTaken": "{extension} artıq {category} tərəfindən istifadə olunur",
|
||||
"builtInCategory": "Daxili kateqoriya silinə bilməz",
|
||||
"selectCategory": "Kateqoriya Seç",
|
||||
"quickDirectories": "Sürətli qovluqlar",
|
||||
@@ -377,6 +384,7 @@
|
||||
"selectAtLeastOneFile": "Ən azı bir fayl seçin",
|
||||
"resolvedFiles": "Tapılmış Fayllar",
|
||||
"resolvedFileCount": "{count} fayl tapıldı",
|
||||
"resolvedCategoryDirectory": "{path} qovluğuna saxlanılacaq",
|
||||
"resourceHasNoFiles": "Bu resursda seçilə bilən fayl siyahısı yoxdur",
|
||||
"noFiles": "Fayl yoxdur",
|
||||
"historyLinks": "Tarixçə keçidləri",
|
||||
|
||||
@@ -203,6 +203,13 @@
|
||||
"categoryProgram": "Programa",
|
||||
"categoryName": "Nom de la categoria",
|
||||
"categoryPath": "Ruta de la categoria",
|
||||
"autoCategorizeByExtension": "Categoritza automàticament segons el tipus de fitxer",
|
||||
"autoCategorizeByExtensionDescription": "Les baixades es desen a la carpeta de categoria segons l'extensió del fitxer en utilitzar la carpeta de baixades predeterminada.",
|
||||
"categoryExtensions": "Extensions de fitxer",
|
||||
"categoryExtensionsUsageHint": "Separa les extensions amb comes o espais i prem Retorn.",
|
||||
"categoryExtensionsEmptyHint": "Sense extensions, els fitxers no es desaran aquí automàticament.",
|
||||
"categoryExtensionsRestoreDefaults": "Restaurar les extensions predeterminades",
|
||||
"categoryExtensionTaken": "{extension} ja està utilitzat per {category}",
|
||||
"builtInCategory": "La categoria integrada no es pot suprimir",
|
||||
"selectCategory": "Seleccioneu una categoria",
|
||||
"quickDirectories": "Carpetes ràpides",
|
||||
@@ -363,6 +370,7 @@
|
||||
"selectAtLeastOneFile": "Seleccioneu com a mínim un fitxer",
|
||||
"resolvedFiles": "Fitxers resolts",
|
||||
"resolvedFileCount": "{count}",
|
||||
"resolvedCategoryDirectory": "Es desarà a {path}",
|
||||
"resourceHasNoFiles": "El recurs no té cap llista de fitxers per seleccionar",
|
||||
"noFiles": "Sense fitxers",
|
||||
"historyLinks": "Enllaços de l'historial",
|
||||
|
||||
@@ -203,6 +203,13 @@
|
||||
"categoryProgram": "Programm",
|
||||
"categoryName": "Kategoriename",
|
||||
"categoryPath": "Kategoriepfad",
|
||||
"autoCategorizeByExtension": "Nach Dateityp automatisch kategorisieren",
|
||||
"autoCategorizeByExtensionDescription": "Downloads werden beim Speichern im Standard-Download-Ordner anhand der Dateiendung automatisch einem Kategorieordner zugeordnet.",
|
||||
"categoryExtensions": "Dateiendungen",
|
||||
"categoryExtensionsUsageHint": "Trenne Endungen mit Kommas oder Leerzeichen und drücke Enter.",
|
||||
"categoryExtensionsEmptyHint": "Ohne Endungen werden Dateien nicht automatisch dieser Kategorie zugeordnet.",
|
||||
"categoryExtensionsRestoreDefaults": "Standard-Endungen wiederherstellen",
|
||||
"categoryExtensionTaken": "{extension} wird bereits von {category} verwendet",
|
||||
"builtInCategory": "Integrierte Kategorie kann nicht gelöscht werden",
|
||||
"selectCategory": "Kategorie auswählen",
|
||||
"quickDirectories": "Schnellordner",
|
||||
@@ -363,6 +370,7 @@
|
||||
"selectAtLeastOneFile": "Wählen Sie mindestens eine Datei aus",
|
||||
"resolvedFiles": "Aufgelöste Dateien",
|
||||
"resolvedFileCount": "{count}-Dateien behoben",
|
||||
"resolvedCategoryDirectory": "Wird in {path} gespeichert",
|
||||
"resourceHasNoFiles": "Die Ressource verfügt über keine auswählbare Dateiliste",
|
||||
"noFiles": "Keine Dateien",
|
||||
"historyLinks": "Verlaufslinks",
|
||||
|
||||
@@ -217,6 +217,23 @@
|
||||
"categoryProgram": "Program",
|
||||
"categoryName": "Category Name",
|
||||
"categoryPath": "Category Path",
|
||||
"autoCategorizeByExtension": "Auto-categorize by file type",
|
||||
"autoCategorizeByExtensionDescription": "Automatically route downloads into a category folder based on the file name when saving to the default download folder.",
|
||||
"categoryExtensions": "Associated Extensions",
|
||||
"categoryExtensionsUsageHint": "Separate suffixes with commas or spaces, then press Enter.",
|
||||
"categoryExtensionsEmptyHint": "Without extensions, files won't be saved into this category automatically.",
|
||||
"categoryExtensionsRestoreDefaults": "Restore default extensions",
|
||||
"categoryExtensionTaken": "{extension} is already used by {category}",
|
||||
"@categoryExtensionTaken": {
|
||||
"placeholders": {
|
||||
"extension": {
|
||||
"type": "String"
|
||||
},
|
||||
"category": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"builtInCategory": "Built-in category cannot be deleted",
|
||||
"selectCategory": "Select Category",
|
||||
"quickDirectories": "Quick folders",
|
||||
@@ -387,6 +404,14 @@
|
||||
"selectAtLeastOneFile": "Select at least one file",
|
||||
"resolvedFiles": "Resolved Files",
|
||||
"resolvedFileCount": "{count, plural, =1{1 file resolved} other{{count} files resolved}}",
|
||||
"resolvedCategoryDirectory": "Will be saved to {path}",
|
||||
"@resolvedCategoryDirectory": {
|
||||
"placeholders": {
|
||||
"path": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"resourceHasNoFiles": "The resource has no selectable file list",
|
||||
"noFiles": "No files",
|
||||
"historyLinks": "History Links",
|
||||
|
||||
@@ -203,6 +203,13 @@
|
||||
"categoryProgram": "Programa",
|
||||
"categoryName": "Nombre de categoría",
|
||||
"categoryPath": "Ruta de categoría",
|
||||
"autoCategorizeByExtension": "Categorizar automáticamente por tipo de archivo",
|
||||
"autoCategorizeByExtensionDescription": "Las descargas se guardan automáticamente en la carpeta de categoría según la extensión del archivo al usar la carpeta de descargas predeterminada.",
|
||||
"categoryExtensions": "Extensiones de archivo",
|
||||
"categoryExtensionsUsageHint": "Separa las extensiones con comas o espacios y pulsa Intro.",
|
||||
"categoryExtensionsEmptyHint": "Sin extensiones, los archivos no se guardarán automáticamente en esta categoría.",
|
||||
"categoryExtensionsRestoreDefaults": "Restaurar extensiones predeterminadas",
|
||||
"categoryExtensionTaken": "{extension} ya está en uso en {category}",
|
||||
"builtInCategory": "La categoría incorporada no se puede eliminar",
|
||||
"selectCategory": "Seleccionar categoría",
|
||||
"quickDirectories": "Carpetas rápidas",
|
||||
@@ -363,6 +370,7 @@
|
||||
"selectAtLeastOneFile": "Seleccione al menos un archivo",
|
||||
"resolvedFiles": "Archivos resueltos",
|
||||
"resolvedFileCount": "{count} resueltos",
|
||||
"resolvedCategoryDirectory": "Se guardará en {path}",
|
||||
"resourceHasNoFiles": "El recurso no tiene lista de archivos seleccionable",
|
||||
"noFiles": "No hay archivos",
|
||||
"historyLinks": "Enlaces del historial",
|
||||
|
||||
@@ -203,6 +203,13 @@
|
||||
"categoryProgram": "برنامه",
|
||||
"categoryName": "نام دستهبندی",
|
||||
"categoryPath": "مسیر دستهبندی",
|
||||
"autoCategorizeByExtension": "دستهبندی خودکار بر اساس نوع فایل",
|
||||
"autoCategorizeByExtensionDescription": "هنگام ذخیره در پوشه پیشفرض دانلود، دانلودها بر اساس پسوند فایل به پوشه دسته مربوطه منتقل میشوند.",
|
||||
"categoryExtensions": "پسوندهای فایل",
|
||||
"categoryExtensionsUsageHint": "پسوندها را با ویرگول یا فاصله جدا کنید و Enter را بزنید.",
|
||||
"categoryExtensionsEmptyHint": "بدون پسوندها، فایلها بهصورت خودکار در این دسته ذخیره نمیشوند.",
|
||||
"categoryExtensionsRestoreDefaults": "بازیابی پسوندهای پیشفرض",
|
||||
"categoryExtensionTaken": "{extension} از قبل توسط {category} استفاده شده است",
|
||||
"builtInCategory": "دستهبندی داخلی قابل حذف نیست",
|
||||
"selectCategory": "انتخاب دستهبندی",
|
||||
"quickDirectories": "پوشههای سریع",
|
||||
@@ -363,6 +370,7 @@
|
||||
"selectAtLeastOneFile": "حداقل یک فایل را انتخاب کنید",
|
||||
"resolvedFiles": "فایل های حل شده",
|
||||
"resolvedFileCount": "{count} فایل شناسایی شد",
|
||||
"resolvedCategoryDirectory": "در {path} ذخیره میشود",
|
||||
"resourceHasNoFiles": "منبع هیچ لیست فایل قابل انتخاب ندارد",
|
||||
"noFiles": "فایل ها",
|
||||
"historyLinks": "پیوندهای تاریخچه",
|
||||
|
||||
@@ -203,6 +203,13 @@
|
||||
"categoryProgram": "Programme",
|
||||
"categoryName": "Nom de la catégorie",
|
||||
"categoryPath": "Chemin de la catégorie",
|
||||
"autoCategorizeByExtension": "Catégoriser automatiquement selon le type de fichier",
|
||||
"autoCategorizeByExtensionDescription": "Les téléchargements sont placés dans un dossier de catégorie selon l'extension du fichier lors de l'enregistrement dans le dossier de téléchargement par défaut.",
|
||||
"categoryExtensions": "Extensions de fichier",
|
||||
"categoryExtensionsUsageHint": "Séparez les extensions par virgules ou espaces, puis appuyez sur Entrée.",
|
||||
"categoryExtensionsEmptyHint": "Sans extensions, les fichiers ne seront pas enregistrés ici automatiquement.",
|
||||
"categoryExtensionsRestoreDefaults": "Restaurer les extensions par défaut",
|
||||
"categoryExtensionTaken": "{extension} est déjà utilisé par {category}",
|
||||
"builtInCategory": "La catégorie intégrée ne peut pas être supprimée",
|
||||
"selectCategory": "Sélectionner une catégorie",
|
||||
"quickDirectories": "Dossiers rapides",
|
||||
@@ -363,6 +370,7 @@
|
||||
"selectAtLeastOneFile": "Sélectionnez au moins un fichier",
|
||||
"resolvedFiles": "Fichiers résolus",
|
||||
"resolvedFileCount": "{count} résolus",
|
||||
"resolvedCategoryDirectory": "Sera enregistré dans {path}",
|
||||
"resourceHasNoFiles": "La ressource n'a pas de liste de fichiers sélectionnable",
|
||||
"noFiles": "Aucun fichier",
|
||||
"historyLinks": "Liens de l'historique",
|
||||
|
||||
@@ -203,6 +203,13 @@
|
||||
"categoryProgram": "program",
|
||||
"categoryName": "Kategória neve",
|
||||
"categoryPath": "Kategória útvonala",
|
||||
"autoCategorizeByExtension": "Automatikus kategorizálás fájltípus szerint",
|
||||
"autoCategorizeByExtensionDescription": "Az alapértelmezett letöltési mappa használatakor a letöltések a fájl kiterjesztése alapján automatikusan a kategória mappájába kerülnek.",
|
||||
"categoryExtensions": "Fájlkiterjesztések",
|
||||
"categoryExtensionsUsageHint": "A kiterjesztéseket vesszővel vagy szóközzel válaszd el, majd nyomd le az Enter billentyűt.",
|
||||
"categoryExtensionsEmptyHint": "Kiterjesztések nélkül a fájlok nem kerülnek automatikusan ebbe a kategóriába.",
|
||||
"categoryExtensionsRestoreDefaults": "Alapértelmezett kiterjesztések visszaállítása",
|
||||
"categoryExtensionTaken": "A(z) {extension} már használatban van itt: {category}",
|
||||
"builtInCategory": "A beépített kategória nem törölhető",
|
||||
"selectCategory": "Kategória kiválasztása",
|
||||
"quickDirectories": "Gyorsmappák",
|
||||
@@ -363,6 +370,7 @@
|
||||
"selectAtLeastOneFile": "Válasszon ki legalább egy fájlt",
|
||||
"resolvedFiles": "Feloldott fájlok",
|
||||
"resolvedFileCount": "{count} fájlok megoldva",
|
||||
"resolvedCategoryDirectory": "Ide lesz mentve: {path}",
|
||||
"resourceHasNoFiles": "Az erőforrásnak nincs kiválasztható fájllistája",
|
||||
"noFiles": "Nincsenek fájlok",
|
||||
"historyLinks": "Előzményhivatkozások",
|
||||
|
||||
@@ -203,6 +203,13 @@
|
||||
"categoryProgram": "Program",
|
||||
"categoryName": "Nama Kategori",
|
||||
"categoryPath": "Path Kategori",
|
||||
"autoCategorizeByExtension": "Kategorikan otomatis berdasarkan jenis file",
|
||||
"autoCategorizeByExtensionDescription": "Unduhan dialihkan ke folder kategori berdasarkan ekstensi file saat disimpan ke folder unduhan bawaan.",
|
||||
"categoryExtensions": "Ekstensi file",
|
||||
"categoryExtensionsUsageHint": "Pisahkan ekstensi dengan koma atau spasi, lalu tekan Enter.",
|
||||
"categoryExtensionsEmptyHint": "Tanpa ekstensi, file tidak akan otomatis disimpan ke kategori ini.",
|
||||
"categoryExtensionsRestoreDefaults": "Pulihkan ekstensi default",
|
||||
"categoryExtensionTaken": "{extension} sudah digunakan oleh {category}",
|
||||
"builtInCategory": "Kategori bawaan tidak dapat dihapus",
|
||||
"selectCategory": "Pilih Kategori",
|
||||
"quickDirectories": "Folder cepat",
|
||||
@@ -363,6 +370,7 @@
|
||||
"selectAtLeastOneFile": "Pilih paling tidak satu berkas",
|
||||
"resolvedFiles": "Berkas Terpecahkan",
|
||||
"resolvedFileCount": "{count} terselesaikan",
|
||||
"resolvedCategoryDirectory": "Akan disimpan ke {path}",
|
||||
"resourceHasNoFiles": "Sumber daya tidak memiliki daftar berkas yang dapat dipilih",
|
||||
"noFiles": "Tidak ada berkas",
|
||||
"historyLinks": "Tautan Riwayat",
|
||||
|
||||
@@ -203,6 +203,13 @@
|
||||
"categoryProgram": "Programma",
|
||||
"categoryName": "Nome categoria",
|
||||
"categoryPath": "Percorso categoria",
|
||||
"autoCategorizeByExtension": "Categorizza automaticamente per tipo di file",
|
||||
"autoCategorizeByExtensionDescription": "Il download viene instradato automaticamente nella cartella della categoria in base all'estensione quando si salva nella cartella di download predefinita.",
|
||||
"categoryExtensions": "Estensioni file",
|
||||
"categoryExtensionsUsageHint": "Separa le estensioni con virgole o spazi e premi Invio.",
|
||||
"categoryExtensionsEmptyHint": "Senza estensioni, i file non verranno salvati qui automaticamente.",
|
||||
"categoryExtensionsRestoreDefaults": "Ripristina estensioni predefinite",
|
||||
"categoryExtensionTaken": "{extension} è già in uso in {category}",
|
||||
"builtInCategory": "La categoria predefinita non può essere eliminata",
|
||||
"selectCategory": "Seleziona categoria",
|
||||
"quickDirectories": "Cartelle rapide",
|
||||
@@ -363,6 +370,7 @@
|
||||
"selectAtLeastOneFile": "Selezionare almeno un file",
|
||||
"resolvedFiles": "File recuperati",
|
||||
"resolvedFileCount": "{count} risolti",
|
||||
"resolvedCategoryDirectory": "Verrà salvato in {path}",
|
||||
"resourceHasNoFiles": "La risorsa non ha un elenco di file selezionabile",
|
||||
"noFiles": "Nessun file",
|
||||
"historyLinks": "Link della cronologia",
|
||||
|
||||
@@ -203,6 +203,13 @@
|
||||
"categoryProgram": "プログラム",
|
||||
"categoryName": "カテゴリ名",
|
||||
"categoryPath": "カテゴリパス",
|
||||
"autoCategorizeByExtension": "ファイル種別で自動分類",
|
||||
"autoCategorizeByExtensionDescription": "既定のダウンロードフォルダーに保存する際、拡張子に基づいて分類フォルダーへ自動的に振り分けます。",
|
||||
"categoryExtensions": "ファイル拡張子",
|
||||
"categoryExtensionsUsageHint": "カンマやスペースで拡張子を区切り、Enterで追加します。",
|
||||
"categoryExtensionsEmptyHint": "拡張子が未設定の場合、ファイルは自動的にこの分類へ保存されません。",
|
||||
"categoryExtensionsRestoreDefaults": "デフォルトの拡張子を復元",
|
||||
"categoryExtensionTaken": "{extension} は {category} で既に使用されています",
|
||||
"builtInCategory": "内蔵カテゴリは削除できません",
|
||||
"selectCategory": "カテゴリを選択",
|
||||
"quickDirectories": "クイックフォルダー",
|
||||
@@ -363,6 +370,7 @@
|
||||
"selectAtLeastOneFile": "少なくとも1つのファイルを選択します",
|
||||
"resolvedFiles": "解決されたファイル",
|
||||
"resolvedFileCount": "{count} ファイルが解決しました",
|
||||
"resolvedCategoryDirectory": "{path} に保存されます",
|
||||
"resourceHasNoFiles": "リソースには、選択可能なファイルリストはありません",
|
||||
"noFiles": "ファイルがない場合",
|
||||
"historyLinks": "履歴リンク",
|
||||
|
||||
@@ -203,6 +203,13 @@
|
||||
"categoryProgram": "프로그램",
|
||||
"categoryName": "카테고리 이름",
|
||||
"categoryPath": "카테고리 경로",
|
||||
"autoCategorizeByExtension": "파일 형식별 자동 분류",
|
||||
"autoCategorizeByExtensionDescription": "기본 다운로드 폴더에 저장할 때 확장자에 따라 분류 폴더로 자동 이동합니다.",
|
||||
"categoryExtensions": "파일 확장자",
|
||||
"categoryExtensionsUsageHint": "쉼표나 공백으로 확장자를 구분하고 Enter를 눌러 추가하세요.",
|
||||
"categoryExtensionsEmptyHint": "확장자가 없으면 파일이 이 분류로 자동 저장되지 않습니다.",
|
||||
"categoryExtensionsRestoreDefaults": "기본 확장자 복원",
|
||||
"categoryExtensionTaken": "{extension}은(는) {category}에서 이미 사용 중입니다",
|
||||
"builtInCategory": "기본 제공 카테고리는 삭제할 수 없습니다",
|
||||
"selectCategory": "카테고리 선택",
|
||||
"quickDirectories": "빠른 폴더",
|
||||
@@ -363,6 +370,7 @@
|
||||
"selectAtLeastOneFile": "최소 1개의 파일을 선택하십시오",
|
||||
"resolvedFiles": "해결된 파일",
|
||||
"resolvedFileCount": "{count} 파일 해결",
|
||||
"resolvedCategoryDirectory": "{path}에 저장됩니다",
|
||||
"resourceHasNoFiles": "리소스에는 선택 가능한 파일 목록이 없습니다",
|
||||
"noFiles": "파일 없음",
|
||||
"historyLinks": "기록 링크",
|
||||
|
||||
@@ -203,6 +203,13 @@
|
||||
"categoryProgram": "Programy",
|
||||
"categoryName": "Nazwa kategorii",
|
||||
"categoryPath": "Ścieżka kategorii",
|
||||
"autoCategorizeByExtension": "Automatyczna kategorizacja według typu pliku",
|
||||
"autoCategorizeByExtensionDescription": "Pobierane pliki są automatycznie zapisywane w folderze kategorii na podstawie rozszerzenia, gdy używany jest domyślny folder pobierania.",
|
||||
"categoryExtensions": "Rozszerzenia plików",
|
||||
"categoryExtensionsUsageHint": "Rozdzielaj rozszerzenia przecinkami lub spacjami i naciskaj Enter.",
|
||||
"categoryExtensionsEmptyHint": "Bez rozszerzeń pliki nie będą automatycznie zapisywane w tej kategorii.",
|
||||
"categoryExtensionsRestoreDefaults": "Przywróć domyślne rozszerzenia",
|
||||
"categoryExtensionTaken": "{extension} jest już używane przez {category}",
|
||||
"builtInCategory": "Wbudowanej kategorii nie można usunąć",
|
||||
"selectCategory": "Wybierz kategorię",
|
||||
"quickDirectories": "Szybkie foldery",
|
||||
@@ -363,6 +370,7 @@
|
||||
"selectAtLeastOneFile": "Wybierz co najmniej jeden plik",
|
||||
"resolvedFiles": "Rozwiązywane pliki",
|
||||
"resolvedFileCount": "{count} plików rozwiązanych",
|
||||
"resolvedCategoryDirectory": "Zostanie zapisane w {path}",
|
||||
"resourceHasNoFiles": "Zasób nie ma listy plików do wyboru",
|
||||
"noFiles": "Brak plików",
|
||||
"historyLinks": "Linki historii",
|
||||
|
||||
@@ -203,6 +203,13 @@
|
||||
"categoryProgram": "Programa",
|
||||
"categoryName": "Nome da Categoria",
|
||||
"categoryPath": "Caminho da Categoria",
|
||||
"autoCategorizeByExtension": "Categorizar automaticamente por tipo de arquivo",
|
||||
"autoCategorizeByExtensionDescription": "Envia os downloads para a pasta da categoria com base na extensão ao salvar na pasta de download padrão.",
|
||||
"categoryExtensions": "Extensões de arquivo",
|
||||
"categoryExtensionsUsageHint": "Separe as extensões com vírgulas ou espaços e prima Enter.",
|
||||
"categoryExtensionsEmptyHint": "Sem extensões, os ficheiros não serão guardados aqui automaticamente.",
|
||||
"categoryExtensionsRestoreDefaults": "Restaurar extensões padrão",
|
||||
"categoryExtensionTaken": "{extension} já está em uso em {category}",
|
||||
"builtInCategory": "Categoria integrada não pode ser excluída",
|
||||
"selectCategory": "Selecionar Categoria",
|
||||
"quickDirectories": "Pastas rápidas",
|
||||
@@ -363,6 +370,7 @@
|
||||
"selectAtLeastOneFile": "Selecione pelo menos um arquivo",
|
||||
"resolvedFiles": "Ficheiros Resolvidos",
|
||||
"resolvedFileCount": "{count} ficheiros resolvidos",
|
||||
"resolvedCategoryDirectory": "Será salvo em {path}",
|
||||
"resourceHasNoFiles": "O recurso não tem lista de arquivos selecionáveis",
|
||||
"noFiles": "Sem arquivos",
|
||||
"historyLinks": "Links do histórico",
|
||||
|
||||
@@ -203,6 +203,13 @@
|
||||
"categoryProgram": "Программа",
|
||||
"categoryName": "Название категории",
|
||||
"categoryPath": "Путь категории",
|
||||
"autoCategorizeByExtension": "Автокатегоризация по типу файла",
|
||||
"autoCategorizeByExtensionDescription": "Загрузки автоматически сохраняются в папку категории по расширению файла, если выбрана папка загрузки по умолчанию.",
|
||||
"categoryExtensions": "Расширения файлов",
|
||||
"categoryExtensionsUsageHint": "Разделяйте расширения запятыми или пробелами и нажимайте Enter.",
|
||||
"categoryExtensionsEmptyHint": "Без расширений файлы не будут автоматически сохранены в эту категорию.",
|
||||
"categoryExtensionsRestoreDefaults": "Восстановить расширения по умолчанию",
|
||||
"categoryExtensionTaken": "{extension} уже используется в {category}",
|
||||
"builtInCategory": "Встроенную категорию нельзя удалить",
|
||||
"selectCategory": "Выбрать категорию",
|
||||
"quickDirectories": "Быстрые папки",
|
||||
@@ -363,6 +370,7 @@
|
||||
"selectAtLeastOneFile": "Выберите хотя бы один файл",
|
||||
"resolvedFiles": "Решенные файлы",
|
||||
"resolvedFileCount": "{count} файлов, разрешенных",
|
||||
"resolvedCategoryDirectory": "Будет сохранено в {path}",
|
||||
"resourceHasNoFiles": "Ресурс не имеет выбираемого списка файлов",
|
||||
"noFiles": "Нет файлов",
|
||||
"historyLinks": "Ссылки в истории",
|
||||
|
||||
@@ -217,6 +217,13 @@
|
||||
"categoryProgram": "Program",
|
||||
"categoryName": "Názov kategórie",
|
||||
"categoryPath": "Cesta kategórie",
|
||||
"autoCategorizeByExtension": "Automatická kategorizácia podľa typu súboru",
|
||||
"autoCategorizeByExtensionDescription": "Sťahovania sa pri ukladaní do predvoleného priečinka sťahovania automaticky presúvajú do priečinka kategórie podľa prípony súboru.",
|
||||
"categoryExtensions": "Prípony súborov",
|
||||
"categoryExtensionsUsageHint": "Oddeľujte prípony čiarkami alebo medzerami a stlačte Enter.",
|
||||
"categoryExtensionsEmptyHint": "Bez prípon sa súbory automaticky neuložia do tejto kategórie.",
|
||||
"categoryExtensionsRestoreDefaults": "Obnoviť predvolené prípony",
|
||||
"categoryExtensionTaken": "{extension} je už používané v {category}",
|
||||
"builtInCategory": "Vstavanú kategóriu nie je možné odstrániť",
|
||||
"selectCategory": "Vybrať kategóriu",
|
||||
"quickDirectories": "Rýchle priečinky",
|
||||
@@ -386,6 +393,7 @@
|
||||
"selectAtLeastOneFile": "Vyberte aspoň jeden súbor",
|
||||
"resolvedFiles": "Vyriešené súbory",
|
||||
"resolvedFileCount": "{count, plural, =1{vyriešený 1 súbor} other{vyriešených {count} súborov}}",
|
||||
"resolvedCategoryDirectory": "Uloží sa do {path}",
|
||||
"resourceHasNoFiles": "Zdroj nemá žiadny vyberateľný zoznam súborov",
|
||||
"noFiles": "Žiadne súbory",
|
||||
"historyLinks": "Odkazy v histórii",
|
||||
|
||||
@@ -203,6 +203,13 @@
|
||||
"categoryProgram": "நிரல்",
|
||||
"categoryName": "வகை பெயர்",
|
||||
"categoryPath": "வகை பாதை",
|
||||
"autoCategorizeByExtension": "கோப்பு வகை வாரியாக தானாக வகைப்படுத்து",
|
||||
"autoCategorizeByExtensionDescription": "இயல்புநிலை பதிவிறக்கக் கோப்புறையில் சேமிக்கும்போது, கோப்பு நீட்டிப்பின் அடிப்படையில் வகைக் கோப்புறைக்கு தானாக அனுப்பப்படும்.",
|
||||
"categoryExtensions": "கோப்பு நீட்டிப்புகள்",
|
||||
"categoryExtensionsUsageHint": "நீட்டிப்புகளை காற்புள்ளிகள் அல்லது இடைவெளிகளால் பிரித்து Enter அழுத்தவும்.",
|
||||
"categoryExtensionsEmptyHint": "நீட்டிப்புகள் இல்லாததால், கோப்புகள் இந்த வகைப்பாட்டில் தானாகச் சேமிக்கப்படாது.",
|
||||
"categoryExtensionsRestoreDefaults": "இயல்புநிலை நீட்டிப்புகளை மீட்டமை",
|
||||
"categoryExtensionTaken": "{extension} ஏற்கனவே {category} இல் பயன்படுத்தப்படுகிறது",
|
||||
"builtInCategory": "உள்ளமைக்கப்பட்ட வகையை நீக்க முடியாது",
|
||||
"selectCategory": "வகையைத் தேர்ந்தெடுக்கவும்",
|
||||
"quickDirectories": "விரைவு கோப்புறைகள்",
|
||||
@@ -363,6 +370,7 @@
|
||||
"selectAtLeastOneFile": "குறைந்தது ஒரு கோப்பையாவது தேர்ந்தெடுக்கவும்",
|
||||
"resolvedFiles": "தீர்க்கப்பட்ட கோப்புகள்",
|
||||
"resolvedFileCount": "{count} கோப்புகள் தீர்க்கப்பட்டன",
|
||||
"resolvedCategoryDirectory": "{path} இல் சேமிக்கப்படும்",
|
||||
"resourceHasNoFiles": "வளத்தில் தேர்ந்தெடுக்கக்கூடிய கோப்பு பட்டியல் இல்லை",
|
||||
"noFiles": "(% 1 மொத்தம்) Items (Folders, Files), Size",
|
||||
"historyLinks": "வரலாற்று இணைப்புகள்",
|
||||
|
||||
@@ -203,6 +203,13 @@
|
||||
"categoryProgram": "Program",
|
||||
"categoryName": "Kategori Adı",
|
||||
"categoryPath": "Kategori Yolu",
|
||||
"autoCategorizeByExtension": "Dosya tipine göre otomatik sınıflandır",
|
||||
"autoCategorizeByExtensionDescription": "Varsayılan indirme klasörüne kaydedilirken dosya uzantısına göre ilgili klasöre otomatik yönlendirir.",
|
||||
"categoryExtensions": "Dosya uzantıları",
|
||||
"categoryExtensionsUsageHint": "Sonekleri virgül veya boşlukla ayırıp Enter'a basın.",
|
||||
"categoryExtensionsEmptyHint": "Uzantı yoksa dosyalar bu kategoriye otomatik olarak kaydedilmez.",
|
||||
"categoryExtensionsRestoreDefaults": "Varsayılan uzantıları geri yükle",
|
||||
"categoryExtensionTaken": "{extension} zaten {category} içinde kullanılıyor",
|
||||
"builtInCategory": "Yerleşik kategori silinemez",
|
||||
"selectCategory": "Kategori Seç",
|
||||
"quickDirectories": "Hızlı klasörler",
|
||||
@@ -363,6 +370,7 @@
|
||||
"selectAtLeastOneFile": "En az bir dosya seçin",
|
||||
"resolvedFiles": "Çözülen Dosyalar",
|
||||
"resolvedFileCount": "{count} dosyaları çözüldü",
|
||||
"resolvedCategoryDirectory": "Şuraya kaydedilecek: {path}",
|
||||
"resourceHasNoFiles": "Kaynak, uygun olmayan dosya listesine sahiptir",
|
||||
"noFiles": "Hiçbir dosya yok",
|
||||
"historyLinks": "Geçmiş Bağlantıları",
|
||||
|
||||
@@ -203,6 +203,13 @@
|
||||
"categoryProgram": "Програма",
|
||||
"categoryName": "Назва категорії",
|
||||
"categoryPath": "Шлях категорії",
|
||||
"autoCategorizeByExtension": "Автокатегоризація за типом файлу",
|
||||
"autoCategorizeByExtensionDescription": "Завантаження автоматично зберігаються в папку категорії за розширенням файлу, якщо вибрано папку завантаження за замовчуванням.",
|
||||
"categoryExtensions": "Розширення файлів",
|
||||
"categoryExtensionsUsageHint": "Розділяйте розширення комами або пробілами та натискайте Enter.",
|
||||
"categoryExtensionsEmptyHint": "Без розширень файли не зберігатимуться в цю категорію автоматично.",
|
||||
"categoryExtensionsRestoreDefaults": "Відновити розширення за мовоччанням",
|
||||
"categoryExtensionTaken": "{extension} уже використовується в {category}",
|
||||
"builtInCategory": "Вбудовану категорію неможливо видалити",
|
||||
"selectCategory": "Вибрати категорію",
|
||||
"quickDirectories": "Швидкі папки",
|
||||
@@ -363,6 +370,7 @@
|
||||
"selectAtLeastOneFile": "Виберіть принаймні один файл",
|
||||
"resolvedFiles": "Знезалізовані файли",
|
||||
"resolvedFileCount": "{count} файли вирішені",
|
||||
"resolvedCategoryDirectory": "Буде збережено в {path}",
|
||||
"resourceHasNoFiles": "Ресурс не має вибраного списку файлів",
|
||||
"noFiles": "Немає файлів",
|
||||
"historyLinks": "Посилання історії",
|
||||
|
||||
@@ -203,6 +203,13 @@
|
||||
"categoryProgram": "Chương trình",
|
||||
"categoryName": "Tên danh mục",
|
||||
"categoryPath": "Đường dẫn danh mục",
|
||||
"autoCategorizeByExtension": "Tự động phân loại theo loại tệp",
|
||||
"autoCategorizeByExtensionDescription": "Tải xuống sẽ được chuyển vào thư mục danh mục dựa trên phần mở rộng tệp khi lưu vào thư mục tải xuống mặc định.",
|
||||
"categoryExtensions": "Phần mở rộng tệp",
|
||||
"categoryExtensionsUsageHint": "Phân tách phần mở rộng bằng dấu phẩy hoặc khoảng trắng rồi nhấn Enter.",
|
||||
"categoryExtensionsEmptyHint": "Không có phần mở rộng, tệp sẽ không tự động được lưu vào danh mục này.",
|
||||
"categoryExtensionsRestoreDefaults": "Khôi phục phần mở rộng mặc định",
|
||||
"categoryExtensionTaken": "{extension} đã được sử dụng trong {category}",
|
||||
"builtInCategory": "Không thể xóa danh mục tích hợp",
|
||||
"selectCategory": "Chọn danh mục",
|
||||
"quickDirectories": "Thư mục nhanh",
|
||||
@@ -363,6 +370,7 @@
|
||||
"selectAtLeastOneFile": "Chọn ít nhất một tập tin",
|
||||
"resolvedFiles": "Các tập tin đã giải quyết",
|
||||
"resolvedFileCount": "{count} tập tin được giải quyết",
|
||||
"resolvedCategoryDirectory": "Sẽ được lưu vào {path}",
|
||||
"resourceHasNoFiles": "Không thể chọn danh sách tập tin",
|
||||
"noFiles": "Không có tập tin",
|
||||
"historyLinks": "Liên kết lịch sử",
|
||||
|
||||
@@ -203,6 +203,13 @@
|
||||
"categoryProgram": "程序",
|
||||
"categoryName": "分类名称",
|
||||
"categoryPath": "分类路径",
|
||||
"autoCategorizeByExtension": "按文件类型自动分类",
|
||||
"autoCategorizeByExtensionDescription": "保存到默认下载目录时,根据文件后缀自动归入对应分类文件夹。",
|
||||
"categoryExtensions": "文件后缀关联",
|
||||
"categoryExtensionsUsageHint": "支持逗号/空格分隔后缀,按回车添加。",
|
||||
"categoryExtensionsEmptyHint": "未设置后缀,文件不会自动归入此分类。",
|
||||
"categoryExtensionsRestoreDefaults": "恢复默认后缀",
|
||||
"categoryExtensionTaken": "{extension}已在{category}中配置",
|
||||
"builtInCategory": "内置分类不能删除",
|
||||
"selectCategory": "选择分类",
|
||||
"quickDirectories": "快捷目录",
|
||||
@@ -363,6 +370,7 @@
|
||||
"selectAtLeastOneFile": "请至少选择一个文件",
|
||||
"resolvedFiles": "已解析文件",
|
||||
"resolvedFileCount": "已解析 {count} 个文件",
|
||||
"resolvedCategoryDirectory": "将保存到 {path}",
|
||||
"resourceHasNoFiles": "该资源没有可选择的文件列表",
|
||||
"noFiles": "没有文件",
|
||||
"historyLinks": "历史链接",
|
||||
|
||||
@@ -203,6 +203,13 @@
|
||||
"categoryProgram": "程式",
|
||||
"categoryName": "分類名稱",
|
||||
"categoryPath": "分類路徑",
|
||||
"autoCategorizeByExtension": "按檔案類型自動分類",
|
||||
"autoCategorizeByExtensionDescription": "儲存至預設下載目錄時,根據檔案副檔名自動歸入對應分類資料夾。",
|
||||
"categoryExtensions": "檔案副檔名",
|
||||
"categoryExtensionsUsageHint": "支援逗號/空白分隔後綴,按回車新增。",
|
||||
"categoryExtensionsEmptyHint": "未設定後綴,檔案不會自動歸入此分類。",
|
||||
"categoryExtensionsRestoreDefaults": "恢復預設副檔名",
|
||||
"categoryExtensionTaken": "{extension}已在{category}中設定",
|
||||
"builtInCategory": "內建分類無法刪除",
|
||||
"selectCategory": "選擇分類",
|
||||
"quickDirectories": "快捷目錄",
|
||||
@@ -363,6 +370,7 @@
|
||||
"selectAtLeastOneFile": "請至少選擇一個檔案",
|
||||
"resolvedFiles": "已解析檔案",
|
||||
"resolvedFileCount": "已解析 {count} 個檔案",
|
||||
"resolvedCategoryDirectory": "將儲存到 {path}",
|
||||
"resourceHasNoFiles": "該資源沒有可選擇的檔案列表",
|
||||
"noFiles": "沒有檔案",
|
||||
"historyLinks": "歷史連結",
|
||||
|
||||
@@ -134,9 +134,13 @@ class _AppPathPickerFieldState extends State<AppPathPickerField> {
|
||||
|
||||
Future<void> _pickPlatformDirectory() async {
|
||||
final path = await DownloadDirectoryPicker.pick(context, currentPath: widget.controller.text.trim());
|
||||
if (!mounted || path == null || path.isEmpty || path == widget.controller.text) return;
|
||||
widget.controller.text = path;
|
||||
_syncTooltipPath(path);
|
||||
if (!mounted || path == null || path.isEmpty) return;
|
||||
if (path != widget.controller.text) {
|
||||
widget.controller.text = path;
|
||||
_syncTooltipPath(path);
|
||||
}
|
||||
// Picking a directory is an explicit choice even when the picked directory
|
||||
// is the one already shown, so the callback always runs.
|
||||
widget.onDirectoryPicked?.call(path);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/api/model/downloader_config.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
|
||||
void main() {
|
||||
test('auto-start tasks uses the backend-owned top-level config field', () {
|
||||
@@ -18,6 +22,17 @@ void main() {
|
||||
expect(DownloaderConfig.fromJson(json).autoStartTasks, isFalse);
|
||||
});
|
||||
|
||||
test('legacy extra downloadCategories is ignored on parse', () {
|
||||
final json = DownloaderConfig().toJson();
|
||||
(json['extra'] as Map<String, dynamic>)['downloadCategories'] = [
|
||||
{'name': 'Music', 'path': '/old/Music'},
|
||||
];
|
||||
|
||||
final decoded = DownloaderConfig.fromJson(json);
|
||||
expect(decoded.categories, isEmpty);
|
||||
expect(decoded.toJson()['extra'], isNot(contains('downloadCategories')));
|
||||
});
|
||||
|
||||
test('REST server settings round-trip through the Go downloader config', () {
|
||||
final config = DownloaderConfig()
|
||||
..api = ApiServerConfig(
|
||||
@@ -78,4 +93,180 @@ void main() {
|
||||
expect(decoded.analyticsEnabled, isFalse);
|
||||
expect(decoded.analyticsClientId, 'client-id');
|
||||
});
|
||||
|
||||
test('categories and the auto-categorize switch round-trip through the config', () {
|
||||
final config = DownloaderConfig()
|
||||
..categories = [
|
||||
DownloadCategory(name: 'Program', path: '/dl/Program', extensions: const ['exe', 'msi']),
|
||||
]
|
||||
..autoCategorize = true;
|
||||
|
||||
final decoded = DownloaderConfig.fromJson(config.toJson());
|
||||
expect(decoded.autoCategorize, isTrue);
|
||||
expect(decoded.categories.single.name, 'Program');
|
||||
expect(decoded.categories.single.extensions, ['exe', 'msi']);
|
||||
});
|
||||
|
||||
test('config parses when the category fields are absent or null', () {
|
||||
final missing = DownloaderConfig().toJson()
|
||||
..remove('categories')
|
||||
..remove('autoCategorize');
|
||||
expect(DownloaderConfig.fromJson(missing).categories, isEmpty);
|
||||
expect(DownloaderConfig.fromJson(missing).autoCategorize, isFalse);
|
||||
|
||||
// The Go backend marshals a nil category slice and an unset switch as null.
|
||||
final nil = DownloaderConfig().toJson()
|
||||
..['categories'] = null
|
||||
..['autoCategorize'] = null;
|
||||
expect(DownloaderConfig.fromJson(nil).categories, isEmpty);
|
||||
expect(DownloaderConfig.fromJson(nil).autoCategorize, isFalse);
|
||||
});
|
||||
|
||||
test('auto categorize can be turned off and round-trips', () {
|
||||
final config = DownloaderConfig(autoCategorize: false);
|
||||
expect(DownloaderConfig.fromJson(config.toJson()).autoCategorize, isFalse);
|
||||
});
|
||||
|
||||
test('auto categorize is off unless the config turns it on', () {
|
||||
expect(DownloaderConfig().autoCategorize, isFalse);
|
||||
expect(DownloaderConfig(autoCategorize: true).autoCategorize, isTrue);
|
||||
});
|
||||
|
||||
test('effectiveExtensions returns the stored list and never falls back', () {
|
||||
expect(DownloadCategory(name: 'Custom', path: '/x', extensions: const ['zip']).effectiveExtensions(), ['zip']);
|
||||
final builtin = DownloadCategory(name: '', nameKey: 'categoryMusic', path: '/m');
|
||||
expect(builtin.effectiveExtensions(), isEmpty);
|
||||
expect(DownloadCategory(name: 'Custom', path: '/x').effectiveExtensions(), isEmpty);
|
||||
});
|
||||
|
||||
test('normalizeCategoryExtensions trims lowercases strips dots dedupes and splits on separators', () {
|
||||
expect(normalizeCategoryExtensions([' .MP3 ', 'exe', '.Exe', 'FLAC', 'zip']), ['mp3', 'exe', 'flac', 'zip']);
|
||||
expect(normalizeCategoryExtensions(['a b,c']), ['a', 'b', 'c']);
|
||||
expect(normalizeCategoryExtensions(['', ' ', '..']), isEmpty);
|
||||
});
|
||||
|
||||
test('effectiveExtensions normalizes stored lists in input order', () {
|
||||
final stored = DownloadCategory(name: 'X', path: '/x', extensions: const [' .ZIP ', 'Exe', 'exe']);
|
||||
expect(stored.effectiveExtensions(), ['zip', 'exe']);
|
||||
});
|
||||
|
||||
group('rebaseBuiltinCategoryPaths', () {
|
||||
DownloadCategory builtin(String categoryPath, {bool isDeleted = false}) => DownloadCategory(
|
||||
name: '',
|
||||
nameKey: 'categoryMusic',
|
||||
path: categoryPath,
|
||||
isBuiltIn: true,
|
||||
isDeleted: isDeleted,
|
||||
extensions: const ['mp3'],
|
||||
);
|
||||
|
||||
test('rebases built-in categories that live under the old directory', () {
|
||||
final config = DownloaderConfig()
|
||||
..categories = [
|
||||
builtin(path.join('/dl', 'Music')),
|
||||
DownloadCategory(name: 'Mine', path: path.join('/dl', 'Mine')),
|
||||
DownloadCategory(
|
||||
name: 'Elsewhere',
|
||||
path: path.join('/other', 'Music'),
|
||||
isBuiltIn: true,
|
||||
nameKey: 'categoryVideo',
|
||||
),
|
||||
builtin(path.join('/dl', 'Old'), isDeleted: true),
|
||||
];
|
||||
|
||||
rebaseBuiltinCategoryPaths(config, oldDir: '/dl', newDir: '/data');
|
||||
|
||||
expect(config.categories[0].path, path.join('/data', 'Music'));
|
||||
expect(config.categories[1].path, path.join('/dl', 'Mine'));
|
||||
expect(config.categories[2].path, path.join('/other', 'Music'));
|
||||
expect(config.categories[3].path, path.join('/dl', 'Old'));
|
||||
});
|
||||
|
||||
test('rebases a built-in category that points at the old directory itself', () {
|
||||
final config = DownloaderConfig()..categories = [builtin('/dl')];
|
||||
rebaseBuiltinCategoryPaths(config, oldDir: '/dl', newDir: '/data');
|
||||
expect(config.categories.single.path, '/data');
|
||||
});
|
||||
|
||||
test('does nothing when the directories are empty or unchanged', () {
|
||||
final config = DownloaderConfig()..categories = [builtin(path.join('/dl', 'Music'))];
|
||||
rebaseBuiltinCategoryPaths(config, oldDir: '/dl', newDir: '/dl');
|
||||
rebaseBuiltinCategoryPaths(config, oldDir: '', newDir: '/data');
|
||||
rebaseBuiltinCategoryPaths(config, oldDir: '/dl', newDir: '');
|
||||
expect(config.categories.single.path, path.join('/dl', 'Music'));
|
||||
});
|
||||
|
||||
test('rebases a windows path that the backend seeded with a backslash', () {
|
||||
// The backend seeds the categories with the separator of its platform.
|
||||
final config = DownloaderConfig()
|
||||
..categories = [
|
||||
builtin(r'C:\Users\me\Downloads\Music'),
|
||||
DownloadCategory(name: 'Mine', path: r'C:\Users\me\Downloads\Mine'),
|
||||
];
|
||||
|
||||
rebaseBuiltinCategoryPaths(config, oldDir: r'C:\Users\me\Downloads', newDir: r'C:\Users\me\New');
|
||||
|
||||
// The joined separator depends on the platform the test runs on, so the
|
||||
// assertion compares the forward slash form.
|
||||
expect(config.categories[0].path.replaceAll(r'\', '/'), 'C:/Users/me/New/Music');
|
||||
expect(config.categories[1].path, r'C:\Users\me\Downloads\Mine');
|
||||
});
|
||||
});
|
||||
|
||||
test('go builtinCategoryExtensions stays in sync with kDefaultCategoryExtensions', () {
|
||||
File? goSource;
|
||||
for (final candidate in ['pkg/base/model.go', '../../pkg/base/model.go']) {
|
||||
final file = File(candidate);
|
||||
if (file.existsSync()) {
|
||||
goSource = file;
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(goSource, isNotNull, reason: 'pkg/base/model.go not found from ${Directory.current}');
|
||||
|
||||
final block = RegExp(
|
||||
r'builtinCategoryExtensions = map\[string\]\[\]string\{([\s\S]*?)\n\}',
|
||||
).firstMatch(goSource!.readAsStringSync());
|
||||
expect(block, isNotNull, reason: 'builtinCategoryExtensions map literal not found in pkg/base/model.go');
|
||||
|
||||
final entryPattern = RegExp(r'"(\w+)":\s*\{([^}]*)\}');
|
||||
final extensionPattern = RegExp(r'"([^"]+)"');
|
||||
final goDefaults = <String, Set<String>>{};
|
||||
for (final entry in entryPattern.allMatches(block!.group(1)!)) {
|
||||
goDefaults[entry.group(1)!] = extensionPattern.allMatches(entry.group(2)!).map((m) => m.group(1)!).toSet();
|
||||
}
|
||||
expect(goDefaults, isNotEmpty, reason: 'no built-in extension entries parsed from pkg/base/model.go');
|
||||
|
||||
final dartDefaults = kDefaultCategoryExtensions.map((key, value) => MapEntry(key, value.toSet()));
|
||||
expect(goDefaults.keys.toSet(), dartDefaults.keys.toSet(), reason: 'built-in category nameKeys drifted');
|
||||
for (final key in goDefaults.keys) {
|
||||
expect(goDefaults[key], dartDefaults[key], reason: 'extension list drifted for $key');
|
||||
}
|
||||
});
|
||||
|
||||
test('runs the extension rule table shared with the backend', () {
|
||||
// pkg/download/testdata/category_matching_cases.json is also read by the Go
|
||||
// test, so a rule that only changes on one side breaks the other side.
|
||||
final cases = jsonDecode(_readSharedRuleTable()) as Map<String, dynamic>;
|
||||
for (final c in cases['normalizeExtensions'] as List) {
|
||||
final input = (c['input'] as List).cast<String>();
|
||||
final expected = (c['expected'] as List).cast<String>();
|
||||
expect(normalizeCategoryExtensions(input), expected, reason: 'normalizeCategoryExtensions($input)');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The rule table is shared with the backend, see
|
||||
/// pkg/download/testdata/category_matching_cases.json.
|
||||
String _readSharedRuleTable() {
|
||||
for (final candidate in [
|
||||
'pkg/download/testdata/category_matching_cases.json',
|
||||
'../../pkg/download/testdata/category_matching_cases.json',
|
||||
]) {
|
||||
final file = File(candidate);
|
||||
if (file.existsSync()) {
|
||||
return file.readAsStringSync();
|
||||
}
|
||||
}
|
||||
throw StateError('category_matching_cases.json not found from ${Directory.current}');
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/api/model/downloader_config.dart';
|
||||
import 'package:gopeed/app/application/downloader_config_init.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
|
||||
void main() {
|
||||
DownloaderConfig configWith({List<DownloadCategory> categories = const [], String downloadDir = ''}) {
|
||||
final config = DownloaderConfig(downloadDir: downloadDir)..categories = List.of(categories);
|
||||
return config;
|
||||
}
|
||||
|
||||
group('loadDownloaderConfig', () {
|
||||
test('persists when the load succeeded and init changed the config', () async {
|
||||
var saved = 0;
|
||||
final config = await loadDownloaderConfig(
|
||||
load: () async => configWith(),
|
||||
save: (_) async => saved++,
|
||||
defaultDownloadDir: () async => '/downloads',
|
||||
);
|
||||
expect(saved, 1);
|
||||
expect(config.categories, hasLength(4));
|
||||
});
|
||||
|
||||
test('does not persist when the initial load failed', () async {
|
||||
var saved = 0;
|
||||
final config = await loadDownloaderConfig(
|
||||
load: () async => throw StateError('backend unavailable'),
|
||||
save: (_) async => saved++,
|
||||
defaultDownloadDir: () async => '/downloads',
|
||||
);
|
||||
expect(saved, 0);
|
||||
// The in-memory fallback still gets usable defaults.
|
||||
expect(config.downloadDir, '/downloads');
|
||||
expect(config.categories, hasLength(4));
|
||||
});
|
||||
|
||||
test('skips persist when init changed nothing', () async {
|
||||
var saved = 0;
|
||||
await loadDownloaderConfig(
|
||||
load: () async => configWith(
|
||||
categories: [
|
||||
DownloadCategory(name: 'Mine', path: '/downloads/Mine', extensions: const ['zip']),
|
||||
],
|
||||
downloadDir: '/downloads',
|
||||
),
|
||||
save: (_) async => saved++,
|
||||
defaultDownloadDir: () async => '/other',
|
||||
);
|
||||
expect(saved, 0);
|
||||
});
|
||||
|
||||
test('persists the default download dir when it is missing', () async {
|
||||
var saved = 0;
|
||||
final config = await loadDownloaderConfig(
|
||||
load: () async => configWith(
|
||||
categories: [
|
||||
DownloadCategory(name: 'Mine', path: '/downloads/Mine', extensions: const ['zip']),
|
||||
],
|
||||
),
|
||||
save: (_) async => saved++,
|
||||
defaultDownloadDir: () async => '/downloads',
|
||||
);
|
||||
expect(saved, 1);
|
||||
expect(config.downloadDir, '/downloads');
|
||||
});
|
||||
});
|
||||
|
||||
group('initDownloaderConfig', () {
|
||||
test('seeds built-in categories with default extensions when empty', () async {
|
||||
final config = configWith();
|
||||
final changed = await initDownloaderConfig(config, defaultDownloadDir: () async => '/downloads');
|
||||
|
||||
expect(changed, isTrue);
|
||||
expect(config.categories.map((c) => c.nameKey).toList(), [
|
||||
'categoryMusic',
|
||||
'categoryVideo',
|
||||
'categoryDocument',
|
||||
'categoryProgram',
|
||||
]);
|
||||
for (final category in config.categories) {
|
||||
expect(category.isBuiltIn, isTrue);
|
||||
expect(category.path, path.join('/downloads', category.nameKey!.replaceFirst('category', '')));
|
||||
expect(category.extensions, kDefaultCategoryExtensions[category.nameKey]);
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps existing categories untouched', () async {
|
||||
final existing = DownloadCategory(name: 'Mine', path: '/downloads/Mine', extensions: const ['zip']);
|
||||
final config = configWith(categories: [existing], downloadDir: '/downloads');
|
||||
final changed = await initDownloaderConfig(config, defaultDownloadDir: () async => '/other');
|
||||
|
||||
expect(changed, isFalse);
|
||||
expect(config.categories, hasLength(1));
|
||||
expect(config.categories.single, same(existing));
|
||||
});
|
||||
|
||||
test('reports a change when only the default download dir was filled', () async {
|
||||
final config = configWith(
|
||||
categories: [
|
||||
DownloadCategory(name: 'Mine', path: '/downloads/Mine', extensions: const ['zip']),
|
||||
],
|
||||
);
|
||||
final changed = await initDownloaderConfig(config, defaultDownloadDir: () async => '/downloads');
|
||||
|
||||
expect(changed, isTrue);
|
||||
expect(config.downloadDir, '/downloads');
|
||||
});
|
||||
|
||||
test('leaves the config untouched when the default download dir is unknown', () async {
|
||||
final config = configWith();
|
||||
final changed = await initDownloaderConfig(config, defaultDownloadDir: () async => '');
|
||||
|
||||
// A relative or unknown directory would make the backend write into its
|
||||
// own working directory, so it is left for the backend to resolve.
|
||||
expect(changed, isFalse);
|
||||
expect(config.downloadDir, isEmpty);
|
||||
expect(config.categories, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
|
||||
import 'package:gopeed/features/settings/presentation/widgets/extension_tag_editor.dart';
|
||||
import 'package:gopeed/shared/theme/app_component_themes.dart';
|
||||
import 'package:gopeed/shared/theme/app_theme.dart';
|
||||
|
||||
Widget _wrap(Widget child) => shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: AppComponentThemes(child: child),
|
||||
);
|
||||
|
||||
List<String> _chipTexts(WidgetTester tester) => tester
|
||||
.widgetList<Text>(find.descendant(of: find.byType(Wrap), matching: find.byType(Text)))
|
||||
.map((text) => text.data ?? '')
|
||||
.toList();
|
||||
|
||||
Color _chipBorder(WidgetTester tester, String tag) {
|
||||
final chip = tester.widget<Container>(find.byKey(ValueKey('extension-chip-$tag')));
|
||||
final border = (chip.decoration! as BoxDecoration).border! as Border;
|
||||
return border.top.color;
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('separators stay in the input until Enter commits them as tags', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
_wrap(ExtensionTagEditor(inputKey: const ValueKey('field'), initialTags: const ['.ZIP', ' Exe', 'exe'])),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(_chipTexts(tester), ['zip', 'exe']);
|
||||
|
||||
await tester.enterText(find.byKey(const ValueKey('field')), 'mp3, flac');
|
||||
await tester.pump();
|
||||
expect(_chipTexts(tester), ['zip', 'exe']);
|
||||
expect(find.text('mp3, flac'), findsOneWidget);
|
||||
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pump();
|
||||
expect(_chipTexts(tester), ['zip', 'exe', 'mp3', 'flac']);
|
||||
expect(find.text('mp3, flac'), findsNothing);
|
||||
|
||||
await tester.enterText(find.byKey(const ValueKey('field')), '.APE');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pump();
|
||||
expect(_chipTexts(tester), ['zip', 'exe', 'mp3', 'flac', 'ape']);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('extension-chip-remove-exe')));
|
||||
await tester.pump();
|
||||
expect(_chipTexts(tester), ['zip', 'mp3', 'flac', 'ape']);
|
||||
expect(find.byKey(const ValueKey('extension-chip-remove-exe')), findsNothing);
|
||||
|
||||
await tester.enterText(find.byKey(const ValueKey('field')), 'DO');
|
||||
final state = tester.state<ExtensionTagEditorState>(find.byType(ExtensionTagEditor));
|
||||
expect(state.flush(), ['zip', 'mp3', 'flac', 'ape', 'do']);
|
||||
await tester.pump();
|
||||
expect(_chipTexts(tester), ['zip', 'mp3', 'flac', 'ape', 'do']);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('typing a duplicate highlights the existing chip without adding it', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
_wrap(ExtensionTagEditor(inputKey: const ValueKey('field'), initialTags: const ['zip', 'exe'])),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final zipIdle = _chipBorder(tester, 'zip');
|
||||
|
||||
await tester.enterText(find.byKey(const ValueKey('field')), ' EXE ');
|
||||
await tester.pump();
|
||||
expect(_chipBorder(tester, 'exe'), isNot(zipIdle));
|
||||
expect(_chipBorder(tester, 'zip'), zipIdle);
|
||||
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pump();
|
||||
expect(_chipTexts(tester), ['zip', 'exe']);
|
||||
expect(_chipBorder(tester, 'exe'), zipIdle);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('placeholder surfaces default extensions only while the editor is empty', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
_wrap(
|
||||
ExtensionTagEditor(
|
||||
inputKey: const ValueKey('field'),
|
||||
initialTags: const [],
|
||||
placeholderText: 'empty hint',
|
||||
usagePlaceholderText: 'usage hint',
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('empty hint'), findsOneWidget);
|
||||
|
||||
await tester.enterText(find.byKey(const ValueKey('field')), 'a');
|
||||
await tester.pump();
|
||||
expect(find.text('empty hint'), findsNothing);
|
||||
expect(find.text('usage hint'), findsNothing);
|
||||
expect(_chipTexts(tester), isEmpty);
|
||||
expect(find.byKey(const ValueKey('extension-restore-defaults')), findsNothing);
|
||||
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pump();
|
||||
expect(_chipTexts(tester), ['a']);
|
||||
expect(find.text('empty hint'), findsNothing);
|
||||
expect(find.text('usage hint'), findsOneWidget);
|
||||
|
||||
await tester.enterText(find.byKey(const ValueKey('field')), 'b');
|
||||
await tester.pump();
|
||||
expect(find.text('usage hint'), findsNothing);
|
||||
|
||||
await tester.enterText(find.byKey(const ValueKey('field')), '');
|
||||
await tester.pump();
|
||||
expect(find.text('usage hint'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('extension-chip-remove-a')));
|
||||
await tester.pump();
|
||||
expect(find.text('empty hint'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('empty state offers a restore-defaults action', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
_wrap(
|
||||
ExtensionTagEditor(
|
||||
inputKey: const ValueKey('field'),
|
||||
initialTags: const [],
|
||||
placeholderText: 'empty state',
|
||||
restoreDefaults: const ['Exe', '.MSI', 'exe'],
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('empty state'), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('extension-restore-defaults')), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('extension-restore-defaults')));
|
||||
await tester.pump();
|
||||
expect(_chipTexts(tester), ['exe', 'msi']);
|
||||
expect(find.text('empty state'), findsNothing);
|
||||
expect(find.byKey(const ValueKey('extension-restore-defaults')), findsNothing);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('extension-chip-remove-exe')));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byKey(const ValueKey('extension-chip-remove-msi')));
|
||||
await tester.pump();
|
||||
expect(find.text('empty state'), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('extension-restore-defaults')), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('extensions owned by another category are blocked with an error', (WidgetTester tester) async {
|
||||
var conflict = false;
|
||||
await tester.pumpWidget(
|
||||
_wrap(
|
||||
ExtensionTagEditor(
|
||||
inputKey: const ValueKey('field'),
|
||||
initialTags: const ['zip'],
|
||||
takenByCategory: const {'aaa': 'Music'},
|
||||
onConflictChanged: (value) => conflict = value,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(conflict, isFalse);
|
||||
|
||||
await tester.enterText(find.byKey(const ValueKey('field')), 'mp3, aaa');
|
||||
await tester.pump();
|
||||
expect(conflict, isTrue);
|
||||
expect(find.text('aaa is already used by Music'), findsOneWidget);
|
||||
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pump();
|
||||
expect(_chipTexts(tester), ['zip', 'mp3']);
|
||||
expect(find.text('aaa'), findsOneWidget);
|
||||
expect(find.text('aaa is already used by Music'), findsOneWidget);
|
||||
expect(conflict, isTrue);
|
||||
|
||||
final state = tester.state<ExtensionTagEditorState>(find.byType(ExtensionTagEditor));
|
||||
expect(state.flush(), ['zip', 'mp3']);
|
||||
expect(find.text('aaa'), findsOneWidget);
|
||||
|
||||
await tester.enterText(find.byKey(const ValueKey('field')), 'zzz');
|
||||
await tester.pump();
|
||||
expect(conflict, isFalse);
|
||||
expect(find.text('aaa is already used by Music'), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('a tag claimed by another category renders red until removed', (WidgetTester tester) async {
|
||||
var conflict = true;
|
||||
await tester.pumpWidget(
|
||||
_wrap(
|
||||
ExtensionTagEditor(
|
||||
inputKey: const ValueKey('field'),
|
||||
initialTags: const ['zip', 'aaa'],
|
||||
takenByCategory: const {'aaa': 'Music'},
|
||||
onConflictChanged: (value) => conflict = value,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final zipIdle = _chipBorder(tester, 'zip');
|
||||
expect(_chipBorder(tester, 'aaa'), isNot(zipIdle));
|
||||
expect(find.text('aaa is already used by Music'), findsOneWidget);
|
||||
|
||||
final state = tester.state<ExtensionTagEditorState>(find.byType(ExtensionTagEditor));
|
||||
expect(state.flush(), ['zip']);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('extension-chip-remove-aaa')));
|
||||
await tester.pump();
|
||||
expect(_chipTexts(tester), ['zip']);
|
||||
expect(find.text('aaa is already used by Music'), findsNothing);
|
||||
expect(conflict, isFalse);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1682,6 +1682,44 @@ void main() {
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('changing the download directory rebases built-in category paths', (WidgetTester tester) async {
|
||||
await _setTestSize(tester, const Size(1024, 900));
|
||||
final settingsController = RebaseSettingsController();
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new),
|
||||
appPlatformControllerProvider.overrideWith(FakePlatformController.new),
|
||||
settingsControllerProvider.overrideWith(() => settingsController),
|
||||
],
|
||||
child: const _SettingsTestApp(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Downloads'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final directoryInput = find.byKey(const ValueKey('download-directory-input'));
|
||||
expect(directoryInput, findsOneWidget);
|
||||
await tester.enterText(directoryInput, '/dl/new');
|
||||
await tester.pump(const Duration(milliseconds: 700));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final saved = settingsController.state.requireValue.config;
|
||||
expect(saved.downloadDir, '/dl/new');
|
||||
expect(
|
||||
{for (final category in saved.categories) category.name: category.path.replaceAll(r'\', '/')},
|
||||
{
|
||||
'Music': '/dl/new/Music',
|
||||
'Video': '/dl/new/Video',
|
||||
'Document': '/dl/new/Document',
|
||||
'Program': '/dl/new/Program',
|
||||
'Mine': '/dl/old/Mine',
|
||||
},
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('android download directory picker only switches to verified raw paths', (WidgetTester tester) async {
|
||||
await _setTestSize(tester, const Size(390, 760));
|
||||
DownloadDirectoryPicker.debugPlatformOverride = TargetPlatform.android;
|
||||
@@ -2526,6 +2564,50 @@ void main() {
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('extensions owned by another category block the add dialog', (WidgetTester tester) async {
|
||||
await _setTestSize(tester, const Size(1024, 900));
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new),
|
||||
appPlatformControllerProvider.overrideWith(FakePlatformController.new),
|
||||
settingsControllerProvider.overrideWith(CategorySettingsController.new),
|
||||
],
|
||||
child: const _SettingsTestApp(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Downloads'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('add-download-category')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byKey(const ValueKey('download-category-name')), '影集');
|
||||
await tester.enterText(find.byKey(const ValueKey('download-category-path')), '/tmp/shows');
|
||||
await tester.enterText(find.byKey(const ValueKey('download-category-extensions-input')), 'dat');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('dat is already used by 资料'), findsOneWidget);
|
||||
AppPrimaryButton saveButton() => tester.widget<AppPrimaryButton>(
|
||||
find.ancestor(of: find.text('Save'), matching: find.byType(AppPrimaryButton)),
|
||||
);
|
||||
expect(saveButton().onPressed, isNull);
|
||||
|
||||
await tester.enterText(find.byKey(const ValueKey('download-category-extensions-input')), 'mkv');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('dat is already used by 资料'), findsNothing);
|
||||
expect(saveButton().onPressed, isNotNull);
|
||||
|
||||
await tester.tap(find.text('Save'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('影集'), findsOneWidget);
|
||||
expect(find.text('/tmp/shows'), findsOneWidget);
|
||||
expect(find.text('dat is already used by 资料'), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('settings content stretches responsively and stays centered', (WidgetTester tester) async {
|
||||
await _setTestSize(tester, const Size(1800, 900));
|
||||
await tester.pumpWidget(
|
||||
@@ -6082,7 +6164,24 @@ class CategorySettingsController extends FakeSettingsController {
|
||||
@override
|
||||
Future<SettingsState> build() async {
|
||||
final config = DownloaderConfig();
|
||||
config.extra.downloadCategories = [DownloadCategory(name: '资料', path: '/tmp/data')];
|
||||
config.categories = [
|
||||
DownloadCategory(name: '资料', path: '/tmp/data', extensions: ['dat']),
|
||||
];
|
||||
return SettingsState(config: config);
|
||||
}
|
||||
}
|
||||
|
||||
class RebaseSettingsController extends FakeSettingsController {
|
||||
@override
|
||||
Future<SettingsState> build() async {
|
||||
final config = DownloaderConfig()..downloadDir = '/dl/old';
|
||||
config.categories = [
|
||||
DownloadCategory(name: 'Music', path: '/dl/old/Music', isBuiltIn: true, nameKey: 'categoryMusic'),
|
||||
DownloadCategory(name: 'Video', path: '/dl/old/Video', isBuiltIn: true, nameKey: 'categoryVideo'),
|
||||
DownloadCategory(name: 'Document', path: '/dl/old/Document', isBuiltIn: true, nameKey: 'categoryDocument'),
|
||||
DownloadCategory(name: 'Program', path: '/dl/old/Program', isBuiltIn: true, nameKey: 'categoryProgram'),
|
||||
DownloadCategory(name: 'Mine', path: '/dl/old/Mine'),
|
||||
];
|
||||
return SettingsState(config: config);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user