feat(tasks): group extension folder downloads (#1594)

This commit is contained in:
WuFeng authored and GitHub committed 2026-11-02 15:39:08 +08:00
1 parent fc0a1e0ef9
commit 1e36599d52
84 files changed
+6690 -881

No files matched your search

+15
View File
@@ -94,6 +94,8 @@ State broadcasting must observe the owning Riverpod state centrally. Do not broa
- Storage RPC methods expose business capabilities such as create-history operations, not raw `box.get` or `box.put` access.
- Prefer batch mutations across the window boundary. For example, save all parsed URLs in one `saveCreateHistory(List<String>)` call.
- New child-window storage requirements must be added to `StorageMethods` and bound in the main registry.
- Config reads, full config saves, and create-history mutations share one main-window queue. Each history mutation reads, changes, and writes the config within that queue. History reads wait for earlier saves.
- `StorageMethods` owns `extra.createHistory`; full config saves preserve its latest value inside the queue so stale settings or child-window snapshots cannot overwrite history.
## 7. Gopeed Backend Migration
@@ -128,3 +130,16 @@ Child-window code must not:
- Parse RPC JSON directly inside a page or widget.
These constraints keep all backend and persistence ownership in the main window and allow multiple child windows to operate concurrently without database locks or duplicated Gopeed runtimes.
## 10. Batch Task Creation
`GopeedMethods.createTaskBatch` carries `CreateTaskBatch` through the existing
codec registry. Each member accepts either `rid` from resolve or `req`, plus
creation `opts`. A nonempty `rid` takes priority. BT selections retain their
original file indexes in `opts.selectFiles`.
Both ordinary and grouped creation return `CreateTaskBatchResult`, an object
with `taskIds` and an optional `parentId`. A successful response completes the
batch, including partial success. The preview tracks completed batches so a
failed batch can be retried without repeating successful batches. Creation does
not accept a group ID to append members to an existing group.
+3 -3
View File
@@ -85,10 +85,10 @@ Future<String> createTask(CreateTask createTask) {
return _parse<String>(() => _request('api/v1/tasks', method: 'POST', data: createTask), (data) => data as String);
}
Future<List<String>> createTaskBatch(CreateTaskBatch createTaskBatch) {
return _parse<List<String>>(
Future<CreateTaskBatchResult> createTaskBatch(CreateTaskBatch createTaskBatch) {
return _parse<CreateTaskBatchResult>(
() => _request('api/v1/tasks/batch', method: 'POST', data: createTaskBatch),
(data) => (data as List<dynamic>).map((e) => e as String).toList(),
(json) => CreateTaskBatchResult.fromJson(json as Map<String, dynamic>),
);
}
@@ -10,7 +10,10 @@ class CreateTaskBatch {
List<CreateTaskBatchItem>? reqs;
Options? opts;
CreateTaskBatch({this.reqs, this.opts});
bool group;
String groupName;
CreateTaskBatch({this.reqs, this.opts, this.group = false, this.groupName = ''});
factory CreateTaskBatch.fromJson(Map<String, dynamic> json) => _$CreateTaskBatchFromJson(json);
@@ -19,11 +22,23 @@ class CreateTaskBatch {
@JsonSerializable()
class CreateTaskBatchItem {
String? rid;
Request? req;
Options? opts;
CreateTaskBatchItem({this.req, this.opts});
CreateTaskBatchItem({this.rid, this.req, this.opts});
factory CreateTaskBatchItem.fromJson(Map<String, dynamic> json) => _$CreateTaskBatchItemFromJson(json);
Map<String, dynamic> toJson() => _$CreateTaskBatchItemToJson(this);
}
@JsonSerializable()
class CreateTaskBatchResult {
final List<String> taskIds;
final String? parentId;
CreateTaskBatchResult({required this.taskIds, this.parentId});
factory CreateTaskBatchResult.fromJson(Map<String, dynamic> json) => _$CreateTaskBatchResultFromJson(json);
Map<String, dynamic> toJson() => _$CreateTaskBatchResultToJson(this);
}
@@ -6,22 +6,55 @@ part of 'create_task_batch.dart';
// JsonSerializableGenerator
// **************************************************************************
CreateTaskBatch _$CreateTaskBatchFromJson(Map<String, dynamic> json) => CreateTaskBatch(
reqs: (json['reqs'] as List<dynamic>?)?.map((e) => CreateTaskBatchItem.fromJson(e as Map<String, dynamic>)).toList(),
opts: json['opts'] == null ? null : Options.fromJson(json['opts'] as Map<String, dynamic>),
);
CreateTaskBatch _$CreateTaskBatchFromJson(Map<String, dynamic> json) =>
CreateTaskBatch(
reqs: (json['reqs'] as List<dynamic>?)
?.map((e) => CreateTaskBatchItem.fromJson(e as Map<String, dynamic>))
.toList(),
opts: json['opts'] == null
? null
: Options.fromJson(json['opts'] as Map<String, dynamic>),
group: json['group'] as bool? ?? false,
groupName: json['groupName'] as String? ?? '',
);
Map<String, dynamic> _$CreateTaskBatchToJson(CreateTaskBatch instance) => <String, dynamic>{
'reqs': ?instance.reqs?.map((e) => e.toJson()).toList(),
'opts': ?instance.opts?.toJson(),
};
Map<String, dynamic> _$CreateTaskBatchToJson(CreateTaskBatch instance) =>
<String, dynamic>{
'reqs': ?instance.reqs?.map((e) => e.toJson()).toList(),
'opts': ?instance.opts?.toJson(),
'group': instance.group,
'groupName': instance.groupName,
};
CreateTaskBatchItem _$CreateTaskBatchItemFromJson(Map<String, dynamic> json) => CreateTaskBatchItem(
req: json['req'] == null ? null : Request.fromJson(json['req'] as Map<String, dynamic>),
opts: json['opts'] == null ? null : Options.fromJson(json['opts'] as Map<String, dynamic>),
);
CreateTaskBatchItem _$CreateTaskBatchItemFromJson(Map<String, dynamic> json) =>
CreateTaskBatchItem(
rid: json['rid'] as String?,
req: json['req'] == null
? null
: Request.fromJson(json['req'] as Map<String, dynamic>),
opts: json['opts'] == null
? null
: Options.fromJson(json['opts'] as Map<String, dynamic>),
);
Map<String, dynamic> _$CreateTaskBatchItemToJson(CreateTaskBatchItem instance) => <String, dynamic>{
Map<String, dynamic> _$CreateTaskBatchItemToJson(
CreateTaskBatchItem instance,
) => <String, dynamic>{
'rid': ?instance.rid,
'req': ?instance.req,
'opts': ?instance.opts,
};
CreateTaskBatchResult _$CreateTaskBatchResultFromJson(
Map<String, dynamic> json,
) => CreateTaskBatchResult(
taskIds: (json['taskIds'] as List<dynamic>).map((e) => e as String).toList(),
parentId: json['parentId'] as String?,
);
Map<String, dynamic> _$CreateTaskBatchResultToJson(
CreateTaskBatchResult instance,
) => <String, dynamic>{
'taskIds': instance.taskIds,
'parentId': ?instance.parentId,
};
+17
View File
@@ -28,7 +28,10 @@ enum ExtractStatus {
class Task {
String id;
String name;
@JsonKey(unknownEnumValue: JsonKey.nullForUndefinedEnumValue)
Protocol? protocol;
TaskGroup? group;
String parentId;
Meta meta;
Status status;
bool uploading;
@@ -47,6 +50,8 @@ class Task {
required this.createdAt,
required this.updatedAt,
this.doneAt,
this.group,
this.parentId = '',
});
factory Task.fromJson(Map<String, dynamic> json) => _$TaskFromJson(json);
@@ -54,6 +59,18 @@ class Task {
Map<String, dynamic> toJson() => _$TaskToJson(this);
}
@JsonSerializable()
class TaskGroup {
final String id;
final String name;
final String path;
final DateTime createdAt;
TaskGroup({required this.id, required this.name, required this.path, required this.createdAt});
factory TaskGroup.fromJson(Map<String, dynamic> json) => _$TaskGroupFromJson(json);
Map<String, dynamic> toJson() => _$TaskGroupToJson(this);
}
@JsonSerializable()
class Progress {
int used;
+19 -1
View File
@@ -16,12 +16,16 @@ Task _$TaskFromJson(Map<String, dynamic> json) => Task(
createdAt: DateTime.parse(json['createdAt'] as String),
updatedAt: DateTime.parse(json['updatedAt'] as String),
doneAt: json['doneAt'] == null ? null : DateTime.parse(json['doneAt'] as String),
)..protocol = $enumDecodeNullable(_$ProtocolEnumMap, json['protocol']);
group: json['group'] == null ? null : TaskGroup.fromJson(json['group'] as Map<String, dynamic>),
parentId: json['parentId'] as String? ?? '',
)..protocol = $enumDecodeNullable(_$ProtocolEnumMap, json['protocol'], unknownValue: JsonKey.nullForUndefinedEnumValue);
Map<String, dynamic> _$TaskToJson(Task instance) => <String, dynamic>{
'id': instance.id,
'name': instance.name,
'protocol': ?_$ProtocolEnumMap[instance.protocol],
'group': ?instance.group?.toJson(),
'parentId': instance.parentId,
'meta': instance.meta.toJson(),
'status': _$StatusEnumMap[instance.status]!,
'uploading': instance.uploading,
@@ -48,6 +52,20 @@ const _$ProtocolEnumMap = {
Protocol.ftp: 'ftp',
};
TaskGroup _$TaskGroupFromJson(Map<String, dynamic> json) => TaskGroup(
id: json['id'] as String,
name: json['name'] as String,
path: json['path'] as String,
createdAt: DateTime.parse(json['createdAt'] as String),
);
Map<String, dynamic> _$TaskGroupToJson(TaskGroup instance) => <String, dynamic>{
'id': instance.id,
'name': instance.name,
'path': instance.path,
'createdAt': instance.createdAt.toIso8601String(),
};
Progress _$ProgressFromJson(Map<String, dynamic> json) => Progress(
used: (json['used'] as num).toInt(),
speed: (json['speed'] as num).toInt(),
@@ -34,8 +34,9 @@ class AppCapabilities {
class LocalAppCapabilities {
LocalAppCapabilities._() : codecs = createAppCapabilityCodecs() {
registry = CapabilityRegistry(codecs);
_bindGopeed(registry);
_bindStorage(registry);
final config = _ConfigStore();
_bindGopeed(registry, config);
_bindStorage(registry, config);
registry.bind(NavigationMethods.showDownloadingTasks, (_) {
AppRouter.showDownloadingTasks();
return const RpcUnit();
@@ -62,6 +63,7 @@ RpcCodecRegistry createAppCapabilityCodecs() {
..register<ResolveResult>((json) => ResolveResult.fromJson(json! as Map<String, dynamic>))
..register<CreateTask>((json) => CreateTask.fromJson(json! as Map<String, dynamic>))
..register<CreateTaskBatch>((json) => CreateTaskBatch.fromJson(json! as Map<String, dynamic>))
..register<CreateTaskBatchResult>((json) => CreateTaskBatchResult.fromJson(json! as Map<String, dynamic>))
..register<DownloaderConfig>((json) => DownloaderConfig.fromJson(json! as Map<String, dynamic>))
..register<TaskRuntimeStatus>((json) => TaskRuntimeStatus.fromJson(json! as Map<String, dynamic>))
..register<List<Task>>(
@@ -80,7 +82,7 @@ RpcCodecRegistry createAppCapabilityCodecs() {
return codecs;
}
void _bindGopeed(CapabilityRegistry registry) {
void _bindGopeed(CapabilityRegistry registry, _ConfigStore configStore) {
registry
..bind(GopeedMethods.triggerProtocolJob, (p) async {
await api.triggerProtocolJob(p['protocol'] as String, p['name'] as String);
@@ -127,9 +129,9 @@ void _bindGopeed(CapabilityRegistry registry) {
await api.deleteTasks(_optionalStringList(params['ids']), params['force'] as bool? ?? false);
return const RpcUnit();
})
..bind(GopeedMethods.getConfig, (_) => api.getConfig())
..bind(GopeedMethods.getConfig, (_) => configStore.get())
..bind(GopeedMethods.putConfig, (config) async {
await api.putConfig(config);
await configStore.put(config);
return const RpcUnit();
})
..bind(GopeedMethods.installExtension, api.installExtension)
@@ -163,35 +165,59 @@ void _bindGopeed(CapabilityRegistry registry) {
});
}
void _bindStorage(CapabilityRegistry registry) {
// Shared by local and child-window handlers. History owns its config field;
// full config snapshots must preserve the latest history inside the same queue.
class _ConfigStore {
Future<void> _tail = Future<void>.value();
Future<T> _run<T>(Future<T> Function() operation) {
final result = _tail.then((_) => operation());
_tail = result.then<void>((_) {}, onError: (Object _, StackTrace _) {});
return result;
}
Future<DownloaderConfig> get() => _run(api.getConfig);
Future<void> put(DownloaderConfig config) {
final snapshot = DownloaderConfig.fromJson(config.toJson());
return _run(() async {
final latest = await api.getConfig();
snapshot.extra.createHistory = latest.extra.createHistory;
await api.putConfig(snapshot);
});
}
Future<void> updateHistory(void Function(List<String>) mutation) => _run(() async {
final config = await api.getConfig();
final history = List<String>.of(config.extra.createHistory);
mutation(history);
config.extra.createHistory = history;
await api.putConfig(config);
});
}
void _bindStorage(CapabilityRegistry registry, _ConfigStore configStore) {
registry
..bind(StorageMethods.getCreateHistory, (_) async {
final config = await api.getConfig();
final config = await configStore.get();
return config.extra.createHistory;
})
..bind(StorageMethods.saveCreateHistory, (urls) async {
final config = await api.getConfig();
final history = List<String>.of(config.extra.createHistory);
for (final url in urls) {
history.remove(url);
history.insert(0, url);
if (history.length > 64) history.removeLast();
}
config.extra.createHistory = history;
await api.putConfig(config);
await configStore.updateHistory((history) {
for (final url in urls) {
history.remove(url);
history.insert(0, url);
if (history.length > 64) history.removeLast();
}
});
return const RpcUnit();
})
..bind(StorageMethods.removeCreateHistory, (url) async {
final config = await api.getConfig();
final history = List<String>.of(config.extra.createHistory)..remove(url);
config.extra.createHistory = history;
await api.putConfig(config);
await configStore.updateHistory((history) => history.remove(url));
return const RpcUnit();
})
..bind(StorageMethods.clearCreateHistory, (_) async {
final config = await api.getConfig();
config.extra.createHistory = const [];
await api.putConfig(config);
await configStore.updateHistory((history) => history.clear());
return const RpcUnit();
});
}
@@ -17,7 +17,7 @@ abstract final class GopeedMethods {
static const getMirrorCandidates = RpcMethod<Map<String, dynamic>, List<String>>('gopeed.mirrors.candidates');
static const resolve = RpcMethod<ResolveTask, ResolveResult>('gopeed.resolve');
static const createTask = RpcMethod<CreateTask, String>('gopeed.task.create');
static const createTaskBatch = RpcMethod<CreateTaskBatch, List<String>>('gopeed.task.createBatch');
static const createTaskBatch = RpcMethod<CreateTaskBatch, CreateTaskBatchResult>('gopeed.task.createBatch');
static const patchTask = RpcMethod<Map<String, dynamic>, RpcUnit>('gopeed.task.patch');
static const getTasks = RpcMethod<List<String>, List<Task>>('gopeed.task.list');
static const getTaskStatus = RpcMethod<String, TaskRuntimeStatus>('gopeed.task.status');
@@ -53,7 +53,7 @@ class GopeedService {
Future<String> createTask(CreateTask request) => _invoker.invoke(GopeedMethods.createTask, request);
Future<List<String>> createTaskBatch(CreateTaskBatch request) =>
Future<CreateTaskBatchResult> createTaskBatch(CreateTaskBatch request) =>
_invoker.invoke(GopeedMethods.createTaskBatch, request);
Future<void> patchTask(String id, PatchTask request) async {
@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform;
import 'package:flutter/material.dart' show Colors, Icons;
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -48,10 +49,10 @@ class _BatchActionAvailability {
_BatchActionAvailability _batchActionAvailability(Iterable<TaskRecord> tasks, TaskBatchSelectionController selection) {
var canPause = false;
var canResume = false;
for (final task in tasks) {
for (final task in tasks.expand((task) => [task, ...task.members])) {
if (!selection.contains(task.id)) continue;
canPause = canPause || task.status == TaskStatus.downloading;
canResume = canResume || task.status == TaskStatus.paused || task.status == TaskStatus.failed;
canPause = canPause || task.canPause;
canResume = canResume || task.canResume;
if (canPause && canResume) break;
}
return _BatchActionAvailability(canPause: canPause, canResume: canResume);
@@ -69,6 +70,7 @@ class _HomePageState extends ConsumerState<HomePage> {
_TaskFilter _activeFilter = _TaskFilter.downloading;
TaskRecord? _selectedTask;
bool _batchMode = false;
final Set<String> _expandedGroupIds = {};
final TaskBatchSelectionController _batchSelection = TaskBatchSelectionController();
@override
@@ -96,11 +98,16 @@ class _HomePageState extends ConsumerState<HomePage> {
final tasksState = tasksAsync.value;
final tasks = tasksState?.tasks ?? const <TaskRecord>[];
final filteredTasks = _filteredTasks(tasks);
final visibleTasks = _visibleTasks(filteredTasks);
final memberIds = {
for (final task in tasks)
for (final member in task.members) member.id,
};
final emptyStateMessage = _searchController.text.trim().isEmpty
? context.l10n.emptyTaskList
: context.l10n.noMatchingTasks;
final selectedTask = _latestSelectedTask(tasks);
final visibleTaskIds = filteredTasks.map((task) => task.id).toList(growable: false);
final selectedTask = _latestSelectedTask();
final visibleTaskIds = visibleTasks.map((task) => task.id).toList(growable: false);
final pendingUpdateTask = ref.watch(pendingUpdateTaskProvider);
final navigationItems = _taskNavigationItems(tasks);
final desktopBody = shad.Scaffold(
@@ -158,7 +165,7 @@ class _HomePageState extends ConsumerState<HomePage> {
listenable: _batchSelection,
builder: (context, _) => _TaskBatchRow(
selectedCount: _batchSelection.count,
totalCount: filteredTasks.length,
totalCount: visibleTasks.length,
allSelected: _batchSelection.areAllSelected(visibleTaskIds),
someSelected:
_batchSelection.isNotEmpty &&
@@ -179,16 +186,19 @@ class _HomePageState extends ConsumerState<HomePage> {
)
: ListView.separated(
padding: const EdgeInsets.only(bottom: 16),
itemCount: filteredTasks.length,
itemCount: visibleTasks.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final task = filteredTasks[index];
final task = visibleTasks[index];
return TaskBatchSelectionBuilder(
key: ValueKey(task.id),
controller: _batchSelection,
taskId: task.id,
visibleTaskIds: visibleTaskIds,
builder: (context, selectedInBatch, allSelected) => TaskCard(
task: task,
nested: memberIds.contains(task.id),
expanded: _expandedGroupIds.contains(task.id),
selected: _selectedTask?.id == task.id,
batchMode: _batchMode,
selectedInBatch: selectedInBatch,
@@ -242,6 +252,8 @@ class _HomePageState extends ConsumerState<HomePage> {
final mobileBody = tasksAsync.when(
loading: () => _MobileTasksView(
tasks: const [],
memberIds: const {},
expandedGroupIds: _expandedGroupIds,
batchMode: _batchMode,
batchSelection: _batchSelection,
loading: true,
@@ -262,7 +274,9 @@ class _HomePageState extends ConsumerState<HomePage> {
),
error: (error, _) => ColoredBox(color: palette.bg, child: const TaskEmptyState()),
data: (_) => _MobileTasksView(
tasks: filteredTasks,
tasks: visibleTasks,
memberIds: memberIds,
expandedGroupIds: _expandedGroupIds,
emptyStateMessage: emptyStateMessage,
onCreateTask: _searchController.text.trim().isEmpty ? _handleAddTask : null,
batchMode: _batchMode,
@@ -331,20 +345,20 @@ class _HomePageState extends ConsumerState<HomePage> {
_TaskFilter.completed => task.status == TaskStatus.completed,
_TaskFilter.failed => task.status == TaskStatus.failed,
};
final passesSearch =
query.isEmpty || task.name.toLowerCase().contains(query) || task.url.toLowerCase().contains(query);
final passesSearch = task.matchesSearch(query);
return passesFilter && passesSearch;
})
.toList(growable: false);
}
TaskRecord? _latestSelectedTask(List<TaskRecord> tasks) {
List<TaskRecord> _visibleTasks(List<TaskRecord> tasks) => [
for (final task in tasks) ...[task, if (_expandedGroupIds.contains(task.id)) ...task.members],
];
TaskRecord? _latestSelectedTask() {
final selectedTask = _selectedTask;
if (selectedTask == null) return null;
for (final task in tasks) {
if (task.id == selectedTask.id) return task;
}
return selectedTask;
return ref.read(tasksControllerProvider).value?.findTask(selectedTask.id);
}
List<ResponsiveNavigationItem<_TaskFilter>> _taskNavigationItems(List<TaskRecord> tasks) {
@@ -376,7 +390,13 @@ class _HomePageState extends ConsumerState<HomePage> {
if (_batchMode) {
_toggleBatchTask(task.id);
} else {
_showTaskDetails(task);
if (task.isGroup) {
setState(() {
if (!_expandedGroupIds.remove(task.id)) _expandedGroupIds.add(task.id);
});
} else {
_showTaskDetails(task);
}
}
}
@@ -406,8 +426,8 @@ class _HomePageState extends ConsumerState<HomePage> {
void _toggleBatchTask(String taskId) => _batchSelection.toggle(taskId);
void _toggleSelectAll() {
final visibleIds = _filteredTasks(
ref.read(tasksControllerProvider).value?.tasks ?? const [],
final visibleIds = _visibleTasks(
_filteredTasks(ref.read(tasksControllerProvider).value?.tasks ?? const []),
).map((task) => task.id).toList(growable: false);
if (_batchSelection.areAllSelected(visibleIds)) {
_batchSelection.clear();
@@ -430,22 +450,20 @@ class _HomePageState extends ConsumerState<HomePage> {
void _pauseSelectedTasks() {
if (_batchMode && _batchSelection.isEmpty) return;
final ids = _batchMode ? _selectedTaskIdsFor((task) => task.status == TaskStatus.downloading) : null;
final ids = _batchMode ? _selectedTaskIdsFor((task) => task.canPause) : null;
if (_batchMode && ids!.isEmpty) return;
unawaited(_runTaskAction(() => ref.read(tasksControllerProvider.notifier).pauseAll(ids)));
}
void _resumeSelectedTasks() {
if (_batchMode && _batchSelection.isEmpty) return;
final ids = _batchMode
? _selectedTaskIdsFor((task) => task.status == TaskStatus.paused || task.status == TaskStatus.failed)
: null;
final ids = _batchMode ? _selectedTaskIdsFor((task) => task.canResume) : null;
if (_batchMode && ids!.isEmpty) return;
unawaited(_runTaskAction(() => ref.read(tasksControllerProvider.notifier).resumeAll(ids)));
}
List<String> _selectedTaskIdsFor(bool Function(TaskRecord task) test) {
final tasks = ref.read(tasksControllerProvider).value?.tasks ?? const <TaskRecord>[];
final tasks = ref.read(tasksControllerProvider).value?.allTasks ?? const <TaskRecord>[];
return tasks.where((task) => _batchSelection.contains(task.id) && test(task)).map((task) => task.id).toList();
}
@@ -479,8 +497,8 @@ class _HomePageState extends ConsumerState<HomePage> {
}
List<String> _contextTaskIds(TaskRecord task) {
final visibleIds = _filteredTasks(
ref.read(tasksControllerProvider).value?.tasks ?? const [],
final visibleIds = _visibleTasks(
_filteredTasks(ref.read(tasksControllerProvider).value?.tasks ?? const []),
).map((item) => item.id).toSet();
return <String>{..._batchSelection.where(visibleIds.contains), task.id}.toList(growable: false);
}
@@ -502,9 +520,27 @@ class _HomePageState extends ConsumerState<HomePage> {
if (ids.isEmpty) return;
final runtime = ref.read(appRuntimeControllerProvider).value;
final config = runtime?.downloaderConfig;
final selected =
ref.read(tasksControllerProvider).value?.allTasks.where((task) => ids.contains(task.id)).toList() ??
<TaskRecord>[];
final groupedMemberIds = {
for (final task in selected)
for (final member in task.members) member.id,
};
ids = ids.where((id) => !groupedMemberIds.contains(id)).toList();
selected.removeWhere((task) => groupedMemberIds.contains(task.id));
final hasGroups = selected.any((task) => task.isGroup);
final keepFiles = await showTaskDeleteDialog(
context,
taskCount: ids.length,
memberCount: !hasGroups
? null
: selected.fold<int>(0, (total, task) => total + (task.isGroup ? task.members.length : 1)),
// A task always downloads at least one file; records whose resource
// has no file list (plain single-file downloads) still count as one.
fileCount: !hasGroups
? null
: selected.fold<int>(0, (total, task) => total + (task.files.isEmpty ? 1 : task.files.length)),
keepFiles: config?.extra.lastDeleteTaskKeep ?? false,
);
if (keepFiles == null || !mounted) return;
@@ -518,10 +554,11 @@ class _HomePageState extends ConsumerState<HomePage> {
await ref.read(appRuntimeControllerProvider.notifier).reloadConfig();
});
if (!mounted) return;
_batchSelection.removeAll(ids);
_batchSelection.removeAll({...ids, ...groupedMemberIds});
setState(() {
_batchMode = false;
if (_selectedTask != null && ids.contains(_selectedTask!.id)) {
_expandedGroupIds.removeAll(ids);
if (_selectedTask != null && (ids.contains(_selectedTask!.id) || groupedMemberIds.contains(_selectedTask!.id))) {
_selectedTask = null;
}
});
@@ -569,14 +606,8 @@ class _HomePageState extends ConsumerState<HomePage> {
Future<void> _updateTaskUrl(String id, String url, {Map<String, String> headers = const {}}) async {
try {
await ref.read(tasksControllerProvider.notifier).updateUrl(id, url, headers: headers);
final updatedTasks = ref.read(tasksControllerProvider).value?.tasks ?? const <TaskRecord>[];
for (final task in updatedTasks) {
if (task.id == id) {
if (mounted) {
setState(() => _selectedTask = task);
}
break;
}
if (mounted) {
setState(() => _selectedTask = ref.read(tasksControllerProvider).value?.findTask(id));
}
} catch (error) {
if (!mounted) rethrow;
@@ -586,6 +617,16 @@ class _HomePageState extends ConsumerState<HomePage> {
}
Future<void> _openTask(TaskRecord task) async {
final nativeDirectory =
!Util.isWeb() &&
switch (defaultTargetPlatform) {
TargetPlatform.windows || TargetPlatform.macOS || TargetPlatform.linux => true,
_ => false,
};
if (task.isGroup && !nativeDirectory) {
await browseTaskFiles(context, task, mobile: MediaQuery.sizeOf(context).width < Breakpoints.mobile);
return;
}
final opened = task.isFolder
? await FileExplorer.reveal(task.storagePath)
: await FileExplorer.open(task.storagePath);
@@ -674,6 +715,8 @@ class _TaskBatchRow extends StatelessWidget {
class _MobileTasksView extends StatelessWidget {
const _MobileTasksView({
required this.tasks,
required this.memberIds,
required this.expandedGroupIds,
required this.batchMode,
required this.batchSelection,
required this.onToggleSelectAll,
@@ -695,6 +738,8 @@ class _MobileTasksView extends StatelessWidget {
});
final List<TaskRecord> tasks;
final Set<String> memberIds;
final Set<String> expandedGroupIds;
final bool batchMode;
final TaskBatchSelectionController batchSelection;
final VoidCallback onToggleSelectAll;
@@ -760,11 +805,14 @@ class _MobileTasksView extends StatelessWidget {
itemBuilder: (context, index) {
final task = tasks[index];
return TaskBatchSelectionBuilder(
key: ValueKey(task.id),
controller: batchSelection,
taskId: task.id,
visibleTaskIds: visibleTaskIds,
builder: (context, selectedInBatch, allSelected) => TaskCard(
task: task,
nested: memberIds.contains(task.id),
expanded: expandedGroupIds.contains(task.id),
selected: false,
batchMode: batchMode,
selectedInBatch: selectedInBatch,
@@ -0,0 +1,166 @@
import 'package:path/path.dart' as path;
import '../../../api/model/create_task_batch.dart';
import '../../../api/model/options.dart';
import '../../../api/model/request.dart';
import '../../../api/model/resource.dart';
import '../../../api/model/resolve_result.dart';
import '../../../api/model/resolve_task.dart';
import '../../../core/capabilities/gopeed_capability.dart';
/// IDs identify sources within one preview and are never sent as group IDs.
class PendingDownload {
PendingDownload({required this.id, required this.request, required this.options});
final String id;
final Request request;
final Options options;
ResolveResult? result;
String? resolveError;
String? createError;
final Set<int> selected = {};
bool created = false;
bool resolving = false;
bool get loading => result == null && resolveError == null;
bool get isGroup => result != null && result!.id.isEmpty && result!.res.name.trim().isNotEmpty && files.length > 1;
bool get isFtp => RegExp(r'^(ftps?|ftpes):', caseSensitive: false).hasMatch(request.url.trim());
bool get isBt {
if (result?.id.isEmpty == true) return false;
final url = request.url.trim().toLowerCase();
if (url.startsWith('http:') || url.startsWith('https:') || isFtp) return false;
return url.startsWith('magnet:') ||
url.startsWith('data:application/x-bittorrent;base64,') ||
url.split(RegExp(r'[?#]')).first.endsWith('.torrent');
}
String get name {
if (!isBt &&
(!isFtp || result?.res.name.trim().isEmpty == true) &&
result?.id.isNotEmpty == true &&
files.length == 1 &&
files.single.name.isNotEmpty) {
return files.single.name;
}
if (result?.res.name.trim().isNotEmpty == true) return result!.res.name;
return request.url;
}
List<FileInfo> get files => result?.res.files ?? const [];
bool get hasSelection => !created && result != null && selected.isNotEmpty;
Future<void> resolve(GopeedService service) async {
if (result != null || resolving) return;
resolving = true;
resolveError = null;
try {
result = await service.resolve(ResolveTask(req: request));
selected.addAll(List.generate(files.isEmpty ? 1 : files.length, (index) => index));
} catch (error) {
resolveError = error.toString();
} finally {
resolving = false;
}
}
List<CreateTaskBatchItem> creationItems() {
final indexes = selected.toList()..sort();
if (result!.id.isNotEmpty || files.isEmpty) {
final opts = Options.fromJson(options.toJson());
if (files.isNotEmpty) opts.selectFiles = indexes;
if (indexes.length > 1) _omitChecksum(opts);
return [
CreateTaskBatchItem(
rid: result!.id.isEmpty ? null : result!.id,
req: result!.id.isEmpty ? request : null,
opts: opts,
),
];
}
final group = isGroup;
final resourceName = result!.res.name;
final parentSegments = [
if (!group && options.path.isNotEmpty) options.path,
if (!group && resourceName.trim().isNotEmpty) resourceName,
];
final skipAutoCategorize = options.skipAutoCategorize || resourceName.isNotEmpty || files.length > 1;
final httpUrl = RegExp(r'^https?:', caseSensitive: false);
return indexes.map((index) {
final file = files[index];
final member = Options.fromJson(options.toJson());
member.name = files.length == 1 && options.name.isNotEmpty ? options.name : file.name;
final segments = [...parentSegments, if (file.path.isNotEmpty) file.path];
member.path = segments.isEmpty ? '' : path.joinAll(segments);
// The selected parent directory is persisted separately from members.
member.asDefaultPath = false;
member.skipAutoCategorize = skipAutoCategorize;
final req = file.req ?? request;
member.size = httpUrl.hasMatch(req.url) ? file.size : 0;
if (RegExp(r'^(ftps?|ftpes):', caseSensitive: false).hasMatch(req.url)) {
final extra = OptsExtraFtp.fromJson(Map<String, dynamic>.from(member.extra as Map? ?? const {}));
extra.connections = extra.connections.clamp(0, 32);
member.extra = extra.toJson();
}
if (indexes.length > 1) _omitChecksum(member);
return CreateTaskBatchItem(req: req, opts: member);
}).toList();
}
void _omitChecksum(Options opts) {
if (opts.extra is Map) opts.extra = Map<String, dynamic>.from(opts.extra! as Map)..remove('checksum');
}
}
/// A successful response completes the entire batch, including partial success.
class DownloadSubmission {
DownloadSubmission({required this.sources, required this.request});
final List<PendingDownload> sources;
final CreateTaskBatch request;
Future<void> submit(GopeedService service) async {
for (final source in sources) {
source.createError = null;
}
try {
await service.createTaskBatch(request);
for (final source in sources) {
source.created = true;
}
} catch (error) {
for (final source in sources) {
source.createError = error.toString();
}
}
}
}
List<DownloadSubmission> planDownloads(List<PendingDownload> sources) {
final groups = <DownloadSubmission>[];
final independent = <PendingDownload>[];
for (final source in sources.where((source) => source.hasSelection)) {
if (source.isGroup) {
groups.add(
DownloadSubmission(
sources: [source],
request: CreateTaskBatch(
group: true,
groupName: source.options.name.isNotEmpty ? source.options.name : source.result!.res.name.trim(),
opts: source.options,
reqs: source.creationItems(),
),
),
);
} else {
independent.add(source);
}
}
return [
...groups,
if (independent.isNotEmpty)
DownloadSubmission(
sources: independent,
request: CreateTaskBatch(reqs: [for (final source in independent) ...source.creationItems()]),
),
];
}
@@ -23,6 +23,10 @@ class TasksState {
final int uploadSpeedBytesPerSecond;
final Object? lastError;
Iterable<TaskRecord> get allTasks => tasks.expand((task) => [task, ...task.members]);
TaskRecord? findTask(String id) => allTasks.where((task) => task.id == id).firstOrNull;
TasksState copyWith({
List<TaskRecord>? tasks,
int? downloadSpeedBytesPerSecond,
@@ -122,13 +126,14 @@ class TasksController extends AsyncNotifier<TasksState> {
Future<TasksState> _fetch() async {
final tasks = await ref.read(gopeedServiceProvider).getTasks(api_model.Status.values);
final transferSpeeds = aggregateTaskTransferSpeeds(tasks);
tasks.sort((a, b) {
if (a.status == api_model.Status.running && b.status != api_model.Status.running) return -1;
if (a.status != api_model.Status.running && b.status == api_model.Status.running) return 1;
return b.updatedAt.compareTo(a.updatedAt);
});
return TasksState(
tasks: tasks.map(TaskRecord.fromApi).toList(growable: false),
tasks: taskRecordsFromApi(tasks)
..sort((a, b) {
final aRunning = a.status == TaskStatus.downloading && !a.waiting;
final bRunning = b.status == TaskStatus.downloading && !b.waiting;
if (aRunning != bRunning) return aRunning ? -1 : 1;
return (b.updatedAt ?? b.createdAt!).compareTo(a.updatedAt ?? a.createdAt!);
}),
downloadSpeedBytesPerSecond: transferSpeeds.downloadBytesPerSecond,
uploadSpeedBytesPerSecond: transferSpeeds.uploadBytesPerSecond,
);
@@ -42,6 +42,10 @@ class TaskFileNode {
required this.sizeBytes,
this.downloadedBytes,
this.resourceIndex,
this.status,
this.taskId,
this.accessPath,
this.localPath,
});
final int? resourceIndex;
@@ -49,6 +53,10 @@ class TaskFileNode {
final String name;
final int sizeBytes;
final int? downloadedBytes;
final TaskStatus? status;
final String? taskId;
final String? accessPath;
final String? localPath;
double? get progress {
final downloaded = downloadedBytes;
@@ -68,6 +76,8 @@ class TaskRecord {
required this.files,
required this.uploading,
this.isFolder = false,
this.isGroup = false,
this.members = const [],
this.protocol,
this.requestHeaders = const {},
this.total,
@@ -84,6 +94,8 @@ class TaskRecord {
this.totalBytes,
this.speedBytes,
this.uploadedBytes,
this.uploadSpeedBytes,
this.updatedAt,
this.remainingSeconds,
this.waiting = false,
}) : assert(!uploading || uploadSpeed != null);
@@ -108,16 +120,31 @@ class TaskRecord {
final int? totalBytes;
final int? speedBytes;
final int? uploadedBytes;
final int? uploadSpeedBytes;
final DateTime? updatedAt;
final int? remainingSeconds;
final bool waiting;
final List<TaskFileNode> files;
final bool uploading;
final bool isFolder;
final bool isGroup;
final List<TaskRecord> members;
final api_task.Protocol? protocol;
final Map<String, String> requestHeaders;
bool get canUpdateUrl =>
protocol == api_task.Protocol.http && (status == TaskStatus.paused || status == TaskStatus.failed);
!isGroup && protocol == api_task.Protocol.http && (status == TaskStatus.paused || status == TaskStatus.failed);
bool get canPause => isGroup ? members.any((task) => task.canPause) : status == TaskStatus.downloading;
bool get canResume =>
isGroup ? members.any((task) => task.canResume) : status == TaskStatus.paused || status == TaskStatus.failed;
bool matchesSearch(String query) =>
query.isEmpty ||
name.toLowerCase().contains(query) ||
url.toLowerCase().contains(query) ||
files.any((file) => '${file.path}/${file.name}'.toLowerCase().contains(query)) ||
members.any((member) => member.matchesSearch(query));
bool get isIndeterminate => status == TaskStatus.downloading && !waiting && total == null;
@@ -147,6 +174,7 @@ class TaskRecord {
remainingSeconds: _remainingSeconds(task, totalBytes, downloadedBytes),
downloadDuration: task.progress.used > 0 ? Duration(microseconds: (task.progress.used + 999) ~/ 1000) : null,
createdAt: task.createdAt,
updatedAt: task.updatedAt,
doneAt: task.doneAt,
waiting: task.status == api_task.Status.wait,
progress: progress,
@@ -156,6 +184,7 @@ class TaskRecord {
totalBytes: totalBytes > 0 ? totalBytes : null,
speedBytes: task.progress.speed,
uploadedBytes: task.progress.uploaded,
uploadSpeedBytes: task.progress.uploadSpeed,
files: _fileNodes(task.meta.res?.files, task.meta.opts.selectFiles),
uploading: task.uploading,
isFolder: task.meta.res?.name.isNotEmpty ?? false,
@@ -165,6 +194,186 @@ class TaskRecord {
}
}
List<TaskRecord> taskRecordsFromApi(List<api_task.Task> tasks) {
final members = <String, List<TaskRecord>>{};
final metadata = <String, api_task.TaskGroup>{};
final records = <String, TaskRecord>{};
for (final task in tasks) {
final record = TaskRecord.fromApi(task);
records[task.id] = record;
if (task.parentId.isNotEmpty) {
members.putIfAbsent(task.parentId, () => []).add(record);
if (task.group != null) metadata[task.parentId] = task.group!;
}
}
final emitted = <String>{};
return [
for (final task in tasks)
if (task.parentId.isEmpty || !metadata.containsKey(task.parentId))
records[task.id]!
else if (emitted.add(task.parentId))
_groupRecord(metadata[task.parentId]!, members[task.parentId]!),
];
}
TaskRecord _groupRecord(api_task.TaskGroup group, List<TaskRecord> members) {
final running = members.any((task) => task.status == TaskStatus.downloading && !task.waiting);
final failed = members.any((task) => task.status == TaskStatus.failed);
final paused = members.isEmpty || members.any((task) => task.status == TaskStatus.paused);
final completed = members.isNotEmpty && members.every((task) => task.status == TaskStatus.completed);
final status = running
? TaskStatus.downloading
: failed
? TaskStatus.failed
: paused
? TaskStatus.paused
: completed
? TaskStatus.completed
: TaskStatus.downloading;
final downloaded = members.fold(0, (total, task) => total + (task.downloadedBytes ?? 0));
final total = members.every((task) => task.totalBytes != null || task.status == TaskStatus.completed)
? members.fold(0, (total, task) => total + (task.totalBytes ?? 0))
: null;
final speed = members.fold(
0,
(total, task) => total + (task.status == TaskStatus.downloading && !task.waiting ? task.speedBytes ?? 0 : 0),
);
// The backend freezes a finished single task's speed at bytes over active
// time (markTaskDone); a finished group reports the same ratio across its
// members instead of collapsing to "0 B/s" when the last member completes.
final reportedSpeed = speed > 0 ? speed : (completed ? _averageSpeed(members) ?? 0 : 0);
final uploadSpeed = members.fold(0, (total, task) => total + (task.uploading ? task.uploadSpeedBytes ?? 0 : 0));
final uploading = members.any((task) => task.uploading);
final uploaded = members.fold(0, (total, task) => total + (task.uploadedBytes ?? 0));
final pathContext = path.Context(
style: group.path.contains('\\') || members.any((task) => task.storagePath.contains('\\'))
? path.Style.windows
: path.Style.posix,
);
final root = group.path;
final files = <TaskFileNode>[];
for (final member in members) {
final sourceFiles = member.isFolder
? member.files
: [TaskFileNode(path: '', name: member.name, sizeBytes: member.totalBytes ?? 0)];
// Folder members do not carry per-file progress from the backend; spread
// the member's overall ratio over its files so the tree keeps moving
// instead of showing flat zeros.
final folderTotal = member.isFolder ? member.files.fold<int>(0, (total, file) => total + file.sizeBytes) : 0;
final folderRatio = folderTotal > 0 && member.downloadedBytes != null
? (member.downloadedBytes! / folderTotal).clamp(0.0, 1.0)
: null;
for (final file in sourceFiles) {
final fullPath = member.isFolder
? pathContext.join(member.storagePath, file.path, file.name)
: member.storagePath;
final relative = pathContext.relative(fullPath, from: root).replaceAll('\\', '/');
final String nodePath;
final String nodeName;
if (pathContext.isAbsolute(relative) || relative == '..' || relative.startsWith('../')) {
// Keep unexpected stored paths within the displayed tree.
if (member.isFolder) {
nodePath = path.posix.join(pathContext.basename(member.storagePath), file.path.replaceAll('\\', '/'));
nodeName = file.name;
} else {
nodePath = '';
nodeName = member.name;
}
} else {
nodePath = path.posix.dirname(relative) == '.' ? '' : path.posix.dirname(relative);
nodeName = path.posix.basename(relative);
}
files.add(
TaskFileNode(
resourceIndex: files.length,
path: nodePath,
name: nodeName,
sizeBytes: file.sizeBytes,
downloadedBytes: member.isFolder
? folderRatio != null && file.sizeBytes > 0
? (file.sizeBytes * folderRatio).round()
: null
: member.downloadedBytes,
status: member.status,
taskId: member.id,
localPath: fullPath,
accessPath: member.isFolder
? path.posix.join(file.path.replaceAll('\\', '/'), file.name)
: pathContext.relative(fullPath, from: pathContext.dirname(member.storagePath)).replaceAll('\\', '/'),
),
);
}
}
var updatedAt = group.createdAt;
DateTime? doneAt;
for (final member in members) {
final update = member.updatedAt;
if (update != null && update.isAfter(updatedAt)) updatedAt = update;
if (completed) {
final done = member.doneAt;
if (done != null && (doneAt == null || done.isAfter(doneAt))) doneAt = done;
}
}
// The folder card answers "how long did this download take end to end", so
// a completed group reports its wall-clock span; single tasks keep their
// active-time semantics.
final createdAt = group.createdAt;
final downloadDuration = doneAt != null && doneAt.isAfter(createdAt) ? doneAt.difference(createdAt) : null;
return TaskRecord(
id: group.id,
name: group.name,
status: status,
downloaded: _formatBytes(downloaded),
total: total == null ? null : _formatBytes(total),
downloadedBytes: downloaded,
totalBytes: total,
speed: TransferRateFormatter.format(reportedSpeed).text,
speedBytes: reportedSpeed,
uploadSpeed: uploading ? TransferRateFormatter.format(uploadSpeed).text : null,
uploadedBytes: uploaded,
uploadSpeedBytes: uploadSpeed,
uploading: uploading,
progress: completed
? 1
: total != null && total > 0
? (downloaded / total).clamp(0.0, 1.0)
: null,
remainingSeconds: total != null && speed > 0 && total > downloaded ? ((total - downloaded) / speed).ceil() : null,
waiting: status == TaskStatus.downloading && !running,
downloadDuration: downloadDuration,
url: '',
storagePath: root,
files: files,
isFolder: true,
isGroup: true,
members: members,
createdAt: group.createdAt,
updatedAt: updatedAt,
doneAt: doneAt,
);
}
// The combined average rate of a set of members: downloaded bytes over active
// download time, the same ratio the backend freezes into a finished single
// task's progress. Members without a measurable duration are skipped because
// they would inflate the rate.
int? _averageSpeed(List<TaskRecord> members) {
var downloaded = 0;
var microseconds = 0;
for (final member in members) {
final duration = member.downloadDuration;
if (duration == null || duration.inMicroseconds <= 0) {
continue;
}
downloaded += member.downloadedBytes ?? 0;
microseconds += duration.inMicroseconds;
}
if (microseconds <= 0) {
return null;
}
return (downloaded * Duration.microsecondsPerSecond / microseconds).round();
}
Map<String, String> _requestHeaders(Object? extra) {
if (extra is! Map) return const {};
final header = extra['header'];
@@ -19,8 +19,6 @@ import '../../../../api/model/create_task_batch.dart';
import '../../../../api/model/downloader_config.dart';
import '../../../../api/model/options.dart' as api_options;
import '../../../../api/model/request.dart';
import '../../../../api/model/resolve_result.dart';
import '../../../../api/model/resolve_task.dart';
import '../../../../core/capabilities/app_capabilities.dart';
import '../../../../core/utils/breakpoints.dart';
import '../../../../core/utils/text_wrap.dart';
@@ -39,10 +37,11 @@ import '../../../../shared/widgets/app_toast.dart';
import '../../../../l10n/l10n.dart';
import '../../../../util/util.dart';
import '../../application/pending_create_task.dart';
import '../../application/pending_download.dart';
import '../../application/task_list_navigation.dart';
import '../widgets/history_search_field.dart';
import '../widgets/create_task_action_buttons.dart';
import '../widgets/resolve_file_tree.dart';
import '../widgets/resolve_download_dialog.dart';
class CreateTaskWindowPage extends ConsumerStatefulWidget {
const CreateTaskWindowPage({super.key, this.windowController, this.initialTask});
@@ -92,6 +91,8 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
bool _directDownload = false;
int _protocolTab = 0;
bool _creating = false;
final Map<String, int> _completedSubmissionCounts = {};
int _createSession = 0;
bool _draggingTorrent = false;
bool _asDefaultPath = false;
String _configuredDownloadDirectory = '';
@@ -248,6 +249,8 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
if (task == null || !mounted) return;
final req = task.req;
final opts = task.opts ?? api_options.Options();
_createSession++;
_completedSubmissionCounts.clear();
setState(() {
_initialSize = 0;
_checksumAlgorithm = 'md5';
@@ -871,7 +874,20 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
await windowManager.close();
}
Future<void> _finishCreation() async {
Future<void> _finishCreation(int session) async {
if (!mounted || session != _createSession) return;
if (_asDefaultPath && _directoryManuallySet && _directoryController.text.trim().isNotEmpty) {
try {
final service = ref.read(gopeedServiceProvider);
final config = await service.getConfig();
if (!mounted || session != _createSession) return;
config.downloadDir = _directoryController.text.trim();
await service.putConfig(config);
} catch (error) {
if (mounted && session == _createSession) _showToast(error.toString());
}
}
if (!mounted || session != _createSession) return;
if (widget.windowController != null) {
// Navigation is best-effort after a successful submission. A navigation
// failure must not leave a completed form available for resubmission.
@@ -883,6 +899,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
} else {
ref.read(taskListNavigationProvider.notifier).showDownloading();
}
if (!mounted || session != _createSession) return;
await _closeWindow();
}
@@ -989,53 +1006,74 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
_showToast(context.l10n.downloadLinkValid);
return;
}
if (!_validateAdvancedOptions()) {
final occurrences = <String, int>{};
final pendingUrls = <String>[];
for (final url in urls) {
final occurrence = (occurrences[url] ?? 0) + 1;
occurrences[url] = occurrence;
if (occurrence > (_completedSubmissionCounts[url] ?? 0)) {
pendingUrls.add(url);
}
}
if (pendingUrls.isNotEmpty && !_validateAdvancedOptions()) {
return;
}
final session = _createSession;
setState(() {
_creating = true;
_draggingTorrent = false;
});
try {
await _saveCreateHistory(urls);
final isDirect = _directDownload || urls.length > 1;
if (isDirect) {
await Future.wait(
urls.map((url) {
return ref
.read(gopeedServiceProvider)
.createTask(
CreateTask(
req: _buildRequest(url, protocolUrl: _protocolUrlFor(url)),
opts: _buildOptions(
url: _protocolUrlFor(url),
name: urls.length > 1 ? '' : _renameController.text.trim(),
),
),
);
}),
unawaited(
_saveCreateHistory(urls).catchError((Object error) {
if (mounted && session == _createSession) _showToast(error.toString());
}),
);
if (pendingUrls.isEmpty) {
await _finishCreation(session);
return;
}
final sources = [
for (final entry in pendingUrls.indexed)
PendingDownload(
id: 'source-${entry.$1}',
request: _buildRequest(entry.$2, protocolUrl: _protocolUrlFor(entry.$2)),
options: _buildOptions(
url: _protocolUrlFor(entry.$2),
name: urls.length == 1 ? _renameController.text.trim() : '',
omitChecksum: urls.length > 1,
),
),
];
final service = ref.read(gopeedServiceProvider);
if (_directDownload) {
await service.createTaskBatch(
CreateTaskBatch(
reqs: [for (final source in sources) CreateTaskBatchItem(req: source.request, opts: source.options)],
),
);
await _finishCreation();
await _finishCreation(session);
return;
}
final request = _buildRequest(urls.first, protocolUrl: _protocolUrlFor(urls.first));
final result = await ref.read(gopeedServiceProvider).resolve(ResolveTask(req: request));
if (!mounted) return;
final created = await _showResolveDialog(request, result);
if (!created) {
if (mounted) {
setState(() => _creating = false);
}
return;
}
await _finishCreation();
final created = await showResolveDownloadsDialog(
context,
sources: sources,
service: service,
onBatchCreated: (sources) {
if (!mounted || session != _createSession) return;
for (final source in sources) {
_completedSubmissionCounts.update(source.request.url, (count) => count + 1, ifAbsent: () => 1);
}
},
);
if (!mounted || session != _createSession || !created) return;
await _finishCreation(session);
} catch (error) {
if (mounted) {
_showToast(error.toString());
setState(() => _creating = false);
}
if (mounted && session == _createSession) _showToast(error.toString());
} finally {
if (mounted) setState(() => _creating = false);
}
}
@@ -1298,190 +1336,6 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
_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();
final isMultiFile = selected.length > 1;
if (result.id.isNotEmpty) {
await ref
.read(gopeedServiceProvider)
.createTask(
CreateTask(
rid: result.id,
opts: _buildOptions(url: request.url, selectFiles: selected, omitChecksum: isMultiFile),
),
);
return;
}
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;
return CreateTaskBatchItem(
req: fileRequest,
opts: api_options.Options(
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(url: fileRequest.url, omitChecksum: isMultiFile).extra,
),
);
}).toList();
await ref
.read(gopeedServiceProvider)
.createTaskBatch(
CreateTaskBatch(
reqs: reqs,
opts: _buildOptions(url: request.url, omitChecksum: isMultiFile, skipAutoCategorize: keepStructure),
),
);
return;
}
await ref
.read(gopeedServiceProvider)
.createTask(
CreateTask(
req: request,
opts: _buildOptions(url: request.url),
),
);
}
Future<bool> _showResolveDialog(Request request, ResolveResult result) async {
final initialSelection = List<int>.generate(result.res.files.length, (index) => index);
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,
alignment: Alignment.center,
barrierDismissable: !submitting,
builder: (dialogContext) {
final palette = AppPalette.of(dialogContext);
final screenSize = MediaQuery.sizeOf(dialogContext);
final dialogWidth = screenSize.width < 760 ? screenSize.width - 32 : 720.0;
final treeHeight = screenSize.height < 700 ? screenSize.height * 0.55 : 440.0;
return StatefulBuilder(
builder: (dialogContext, setDialogState) {
Future<void> submit() async {
if (result.res.files.isNotEmpty && selectedIndexes.isEmpty) {
setDialogState(() => errorText = dialogContext.l10n.selectAtLeastOneFile);
return;
}
setDialogState(() {
submitting = true;
errorText = null;
});
try {
await _submitResolved(request, result, selectedIndexes);
if (dialogContext.mounted) {
await closeOverlay(dialogContext, true);
}
} catch (error) {
if (!dialogContext.mounted) return;
setDialogState(() {
submitting = false;
errorText = error.toString();
});
}
}
return AlertDialog(
padding: const EdgeInsets.all(18),
title: Text(
result.res.name.trim().isEmpty ? dialogContext.l10n.resolvedFiles : result.res.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
content: SizedBox(
width: dialogWidth,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
result.res.files.isEmpty
? dialogContext.l10n.resourceHasNoFiles
: 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),
child: result.res.files.isEmpty
? Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: palette.cardBg,
border: Border.all(color: palette.border),
borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius),
),
child: Text(
dialogContext.l10n.noFiles,
style: TextStyle(color: palette.textMuted, fontSize: 13),
),
)
: ResolveFileTree(
files: result.res.files,
initialSelection: initialSelection,
onSelectionChanged: (values) => selectedIndexes = values,
),
),
if (errorText != null) ...[
const SizedBox(height: 10),
Text(errorText!, style: TextStyle(color: palette.error, fontSize: 12)),
],
],
),
),
actions: [
flutter.Flexible(
child: CreateTaskActionButtons(
submitting: submitting,
onCancel: () => closeOverlay(dialogContext, false),
onSubmit: submit,
cancelLabel: dialogContext.l10n.cancel,
submitLabel: dialogContext.l10n.createAction,
cancelButtonKey: const ValueKey('resolve-cancel-button'),
submitButtonKey: const ValueKey('resolve-create-button'),
),
),
],
);
},
);
},
);
final created = await dialog.future;
return created == true;
}
Future<void> _showCreateHistory() async {
List<String> histories;
try {
@@ -21,13 +21,8 @@ class TaskDetailsPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final tasksAsync = ref.watch(tasksControllerProvider);
TaskRecord? task = initialTask;
for (final item in tasksAsync.value?.tasks ?? const <TaskRecord>[]) {
if (item.id == taskId) {
task = item;
break;
}
}
final state = tasksAsync.value;
final task = state == null ? initialTask : state.findTask(taskId);
final palette = AppPalette.of(context);
return AppDetailPage(
@@ -43,12 +38,12 @@ class TaskDetailsPage extends ConsumerWidget {
key: ValueKey(task.id),
task: task,
mobile: true,
onOpenStorage: () => FileExplorer.reveal(task!.storagePath),
onOpenStorage: () => FileExplorer.reveal(task.storagePath),
onUpdateUrl: (url) async {
try {
await ref
.read(tasksControllerProvider.notifier)
.updateUrl(task!.id, url, headers: task.requestHeaders);
.updateUrl(task.id, url, headers: task.requestHeaders);
} catch (error) {
if (context.mounted) {
showAppToast(context, error.toString(), type: AppToastType.error);
@@ -20,13 +20,8 @@ class TaskFilesPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final tasksAsync = ref.watch(tasksControllerProvider);
TaskRecord? task = initialTask;
for (final item in tasksAsync.value?.tasks ?? const <TaskRecord>[]) {
if (item.id == taskId) {
task = item;
break;
}
}
final state = tasksAsync.value;
final task = state == null ? initialTask : state.findTask(taskId);
final palette = AppPalette.of(context);
return shad.Scaffold(
@@ -18,7 +18,7 @@ class CreateTaskActionButtons extends StatelessWidget {
final bool submitting;
final VoidCallback onCancel;
final VoidCallback onSubmit;
final VoidCallback? onSubmit;
final String cancelLabel;
final String submitLabel;
final Key? cancelButtonKey;
@@ -0,0 +1,177 @@
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:flutter/widgets.dart' as flutter show Flexible;
import '../../../../core/capabilities/gopeed_capability.dart';
import '../../../../l10n/l10n.dart';
import '../../../../shared/theme/app_palette.dart';
import '../../../../shared/widgets/app_tooltip.dart';
import '../../application/pending_download.dart';
import 'create_task_action_buttons.dart';
import 'resolve_file_tree.dart';
Future<bool> showResolveDownloadsDialog(
BuildContext context, {
required List<PendingDownload> sources,
required GopeedService service,
ValueChanged<List<PendingDownload>>? onBatchCreated,
}) async {
final dialog = const DialogOverlayHandler().show<bool>(
context: context,
alignment: Alignment.center,
barrierDismissable: false,
builder: (_) => _ResolveDownloadsDialog(sources: sources, service: service, onBatchCreated: onBatchCreated),
);
return await dialog.future == true;
}
class _ResolveDownloadsDialog extends StatefulWidget {
const _ResolveDownloadsDialog({required this.sources, required this.service, required this.onBatchCreated});
final List<PendingDownload> sources;
final GopeedService service;
final ValueChanged<List<PendingDownload>>? onBatchCreated;
@override
State<_ResolveDownloadsDialog> createState() => _ResolveDownloadsDialogState();
}
class _ResolveDownloadsDialogState extends State<_ResolveDownloadsDialog> {
bool _cancelled = false;
bool _submitting = false;
String? _errorText;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _resolveAll());
}
@override
void dispose() {
_cancelled = true;
super.dispose();
}
Future<void> _resolveAll() async {
for (final source in widget.sources) {
if (_cancelled || !mounted) return;
if (source.result != null || source.resolveError != null) continue;
final resolving = source.resolve(widget.service);
setState(() {});
await resolving;
if (_cancelled || !mounted) return;
setState(() {});
}
}
void _cancel() {
_cancelled = true;
closeOverlay(context, false);
}
Future<void> _submit() async {
if (_submitting || widget.sources.any((source) => source.loading)) return;
final batches = planDownloads(widget.sources);
if (batches.isEmpty) return;
setState(() {
_submitting = true;
_errorText = null;
for (final source in widget.sources) {
source.createError = null;
}
});
for (final batch in batches) {
await batch.submit(widget.service);
if (!mounted) return;
if (batch.sources.every((source) => source.created)) {
widget.onBatchCreated?.call(batch.sources);
}
}
final errors = widget.sources.map((source) => source.createError).whereType<String>().toSet();
if (errors.isEmpty) {
await closeOverlay(context, true);
} else {
setState(() {
_submitting = false;
_errorText = errors.join('\n');
});
}
}
@override
Widget build(BuildContext context) {
final screen = MediaQuery.sizeOf(context);
final canCreate =
!_submitting &&
!widget.sources.any((source) => source.loading) &&
widget.sources.any((source) => source.hasSelection);
return AlertDialog(
padding: const EdgeInsets.all(18),
title: Row(
children: [
Expanded(child: Text(context.l10n.resolvedFiles)),
AppTooltip(
message: context.l10n.close,
child: GhostButton(
key: const ValueKey('resolve-close-button'),
onPressed: _submitting ? null : _cancel,
child: const Icon(Icons.close, size: 18),
),
),
],
),
content: SizedBox(
width: screen.width < 760 ? screen.width - 68 : 720,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: (screen.height * 0.48).clamp(120.0, 440.0),
child: IgnorePointer(
ignoring: _submitting,
child: ResolveFileTree(
sources: List.of(widget.sources),
onSelectionChanged: (selection) {
final selectedBySource = <String, List<int>>{};
for (final (sourceId, index) in selection) {
selectedBySource.putIfAbsent(sourceId, () => []).add(index);
}
setState(() {
for (final source in widget.sources) {
if (source.result == null || source.created) continue;
source.selected
..clear()
..addAll(selectedBySource[source.id] ?? const []);
}
});
},
),
),
),
if (_errorText != null)
ConstrainedBox(
constraints: BoxConstraints(maxHeight: screen.height * 0.15),
child: SingleChildScrollView(
child: Text(_errorText!, style: TextStyle(color: AppPalette.of(context).error, fontSize: 12)),
),
),
],
),
),
actions: [
flutter.Flexible(
child: CreateTaskActionButtons(
submitting: _submitting,
onCancel: _cancel,
onSubmit: canCreate ? _submit : null,
cancelLabel: context.l10n.cancel,
submitLabel: context.l10n.createAction,
cancelButtonKey: const ValueKey('resolve-cancel-button'),
submitButtonKey: const ValueKey('resolve-create-button'),
),
),
],
);
}
}
@@ -2,99 +2,150 @@ import 'package:flutter/widgets.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' hide Column, Expanded, Row;
import '../../../../api/model/resource.dart';
import '../../../../core/icons/gopeed_icons.dart';
import '../../../../shared/widgets/app_tooltip.dart';
import '../../application/pending_download.dart';
import '../../../../l10n/l10n.dart';
import '../../../../shared/theme/app_design_tokens.dart';
import '../../../../shared/theme/app_palette.dart';
import '../../../../shared/widgets/file_tree_view.dart';
import '../../../../util/util.dart';
class ResolveFileTree extends StatefulWidget {
const ResolveFileTree({
super.key,
required this.files,
required this.initialSelection,
required this.onSelectionChanged,
});
typedef PendingFileKey = (String, int);
final List<FileInfo> files;
final List<int> initialSelection;
final ValueChanged<List<int>> onSelectionChanged;
class ResolveFileTree extends StatefulWidget {
const ResolveFileTree({super.key, required this.sources, required this.onSelectionChanged});
final List<PendingDownload> sources;
final ValueChanged<Set<PendingFileKey>> onSelectionChanged;
@override
State<ResolveFileTree> createState() => _ResolveFileTreeState();
}
class _ResolveFileTreeState extends State<ResolveFileTree> {
late List<FileTreeItem<int>> _treeItems;
late List<int> _allFileIndexes;
late final Set<int> _selectedIndexes;
late List<FileTreeItem<PendingFileKey>> _treeItems;
late Map<PendingFileKey, FileInfo> _files;
late Map<String, String> _labels;
late Map<String, PendingDownload> _sourcesById;
late Map<String, PendingDownload> _sourcesByRootKey;
final Set<PendingFileKey> _selected = {};
final Set<_FileTypeFilter> _activeTypeFilters = {};
@override
void initState() {
super.initState();
_selectedIndexes = widget.initialSelection.where(_isValidIndex).toSet();
_rebuildTreeItems();
WidgetsBinding.instance.addPostFrameCallback((_) => _notifySelection());
}
@override
void didUpdateWidget(covariant ResolveFileTree oldWidget) {
super.didUpdateWidget(oldWidget);
if (identical(widget.files, oldWidget.files)) return;
_selectedIndexes.removeWhere((index) => !_isValidIndex(index));
_activeTypeFilters.clear();
final previousFiles = _files;
_rebuildTreeItems();
WidgetsBinding.instance.addPostFrameCallback((_) => _notifySelection());
if (previousFiles.length != _files.length || !previousFiles.keys.every(_files.containsKey)) {
_activeTypeFilters.clear();
}
}
void _rebuildTreeItems() {
_files = {};
_labels = {};
_treeItems = [];
_sourcesById = {for (final source in widget.sources) source.id: source};
_sourcesByRootKey = {for (final source in widget.sources) 'folder:${source.id}': source};
_selected.clear();
for (final source in widget.sources) {
if (source.result == null) {
_labels[source.id] = source.name;
_treeItems.add(
FileTreeItem(
key: source.id,
path: source.id,
name: source.name,
size: 0,
data: (source.id, -1),
directoryOnly: true,
),
);
continue;
}
final files = source.files.isEmpty ? [FileInfo(name: source.name)] : source.files;
// A non-empty resource name is a real directory, even for one file.
final hasRoot = source.result!.res.name.trim().isNotEmpty;
if (hasRoot) _labels[source.id] = source.name;
for (final entry in files.indexed) {
final key = (source.id, entry.$1);
final file = entry.$2;
if (!source.created) {
_files[key] = file;
if (source.selected.contains(entry.$1)) _selected.add(key);
}
_treeItems.add(
FileTreeItem<PendingFileKey>(
key: '${source.id}:${entry.$1}',
path: hasRoot ? '${source.id}/${file.path}' : file.path,
namespace: hasRoot ? '' : source.id,
name: file.name,
size: file.size,
data: key,
),
);
}
}
}
@override
Widget build(BuildContext context) {
final selectedCount = _selectedIndexes.length;
final allSelected = _allFileIndexes.isNotEmpty && selectedCount == _allFileIndexes.length;
final partiallySelected = selectedCount > 0 && !allSelected;
final selectedSize = _selectedIndexes.fold<int>(0, (sum, index) => sum + widget.files[index].size);
final allSelected = _files.isNotEmpty && _selected.length == _files.length;
final partial = _selected.isNotEmpty && !allSelected;
return Column(
children: [
Expanded(
child: FileTreeView<int>(
child: FileTreeView<PendingFileKey>(
items: _treeItems,
initialExpandedDepth: 1,
directoryLabels: _labels,
initialExpandedDepth: 2,
keyPrefix: 'resolve-tree',
iconWidgetBuilder: _sourceIcon,
rowHeight: _resolveTreeRowHeight,
contentTextStyle: _resolveFileNameStyle,
headerLeading: ExcludeFocus(
child: Checkbox(
state: allSelected
? CheckboxState.checked
: (partiallySelected ? CheckboxState.indeterminate : CheckboxState.unchecked),
onChanged: _allFileIndexes.isEmpty ? null : (_) => _setFileSelection(_allFileIndexes, !allSelected),
: partial
? CheckboxState.indeterminate
: CheckboxState.unchecked,
onChanged: _files.isEmpty ? null : (_) => _setSelection(_files.keys, !allSelected),
),
),
leadingBuilder: (context, node) {
final indexes = node.leafItems.map((item) => item.data).toList(growable: false);
final nodeSelectedCount = indexes.where(_selectedIndexes.contains).length;
final selected = indexes.isNotEmpty && nodeSelectedCount == indexes.length;
final partial = nodeSelectedCount > 0 && !selected;
final keys = node.leafItems.map((item) => item.data).where(_files.containsKey).toList();
final count = keys.where(_selected.contains).length;
return ExcludeFocus(
child: Checkbox(
state: selected
state: keys.isNotEmpty && count == keys.length
? CheckboxState.checked
: (partial ? CheckboxState.indeterminate : CheckboxState.unchecked),
onChanged: (_) => _setFileSelection(indexes, !selected),
: count > 0
? CheckboxState.indeterminate
: CheckboxState.unchecked,
onChanged: keys.isEmpty ? null : (_) => _setSelection(keys, count != keys.length),
),
);
},
onNodePressed: _toggleNodeSelection,
onNodePressed: (node) {
final keys = node.leafItems.map((item) => item.data).where(_files.containsKey).toList();
if (keys.isNotEmpty) _setSelection(keys, !keys.every(_selected.contains));
},
),
),
const SizedBox(height: 12),
_TreeFooter(
selectedCount: selectedCount,
totalCount: _allFileIndexes.length,
selectedSize: selectedSize,
unknownSize: selectedCount > 0 && selectedSize == 0,
selectedCount: _selected.length,
totalCount: _files.length,
selectedSize: _selected.fold(0, (sum, key) => sum + _files[key]!.size),
unknownSize: _selected.any((key) => _files[key]!.size <= 0),
activeTypeFilters: _activeTypeFilters,
onSelectVideo: () => _toggleTypeFilter(_FileTypeFilter.video),
onSelectAudio: () => _toggleTypeFilter(_FileTypeFilter.audio),
@@ -104,65 +155,63 @@ class _ResolveFileTreeState extends State<ResolveFileTree> {
);
}
bool _isValidIndex(int index) => index >= 0 && index < widget.files.length;
void _rebuildTreeItems() {
_allFileIndexes = List<int>.generate(widget.files.length, (index) => index, growable: false);
_treeItems = widget.files
.asMap()
.entries
.map(
(entry) => FileTreeItem<int>(
key: entry.key.toString(),
path: entry.value.path,
name: entry.value.name,
size: entry.value.size,
data: entry.key,
),
)
.toList(growable: false);
Widget _sourceIcon(BuildContext context, FileTreeNode<PendingFileKey> node) {
final source = node.item == null ? _sourcesByRootKey[node.key] : _sourcesById[node.item!.data.$1];
final palette = AppPalette.of(context);
if (source == null) {
return Icon(node.isFolder ? BootstrapIcons.folder2 : GopeedIcons.file, size: 18, color: palette.textSecondary);
}
final pending = source;
final message = pending.loading
? (pending.resolving ? context.l10n.resolvingResource : context.l10n.waitingToResolve)
: pending.resolveError ?? pending.request.url;
return AppTooltip(
focusable: true,
message: message,
child: Semantics(
label: message,
child: pending.loading
? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator())
: Icon(
pending.resolveError != null
? Icons.error_outline
: node.isFolder && pending.isBt
? GopeedIcons.folderBt
: node.isFolder
? BootstrapIcons.folder2
: GopeedIcons.file,
size: 18,
color: pending.resolveError != null ? palette.error : palette.textSecondary,
),
),
);
}
void _setFileSelection(Iterable<int> indexes, bool selected) {
void _setSelection(Iterable<PendingFileKey> keys, bool selected) {
setState(() {
_activeTypeFilters.clear();
if (selected) {
_selectedIndexes.addAll(indexes);
_selected.addAll(keys);
} else {
_selectedIndexes.removeAll(indexes);
_selected.removeAll(keys);
}
});
_notifySelection();
}
void _toggleNodeSelection(FileTreeNode<int> node) {
final indexes = node.leafItems.map((item) => item.data).toList(growable: false);
final selected = indexes.isNotEmpty && indexes.every(_selectedIndexes.contains);
_setFileSelection(indexes, !selected);
widget.onSelectionChanged(Set.of(_selected));
}
void _toggleTypeFilter(_FileTypeFilter filter) {
setState(() {
if (!_activeTypeFilters.remove(filter)) {
_activeTypeFilters.add(filter);
}
final extensions = <String>{for (final activeFilter in _activeTypeFilters) ...activeFilter.extensions};
_selectedIndexes
if (!_activeTypeFilters.remove(filter)) _activeTypeFilters.add(filter);
final extensions = {for (final active in _activeTypeFilters) ...active.extensions};
_selected
..clear()
..addAll(
widget.files
.asMap()
.entries
_files.entries
.where((entry) => extensions.contains(_extensionOf(entry.value.name)))
.map((entry) => entry.key),
);
});
_notifySelection();
}
void _notifySelection() {
final values = _selectedIndexes.toList()..sort();
widget.onSelectionChanged(values);
widget.onSelectionChanged(Set.of(_selected));
}
}
@@ -1,6 +1,7 @@
import 'package:flutter/widgets.dart';
import '../../../../../core/utils/breakpoints.dart';
import '../../../../../shared/theme/app_design_tokens.dart';
import '../../../domain/task_record.dart';
import '../task_context_menu.dart';
import 'task_card_desktop.dart';
@@ -10,6 +11,8 @@ class TaskCard extends StatelessWidget {
const TaskCard({
super.key,
required this.task,
this.expanded = false,
this.nested = false,
required this.selected,
required this.batchMode,
required this.selectedInBatch,
@@ -25,6 +28,8 @@ class TaskCard extends StatelessWidget {
});
final TaskRecord task;
final bool expanded;
final bool nested;
final bool selected;
final bool batchMode;
final bool selectedInBatch;
@@ -42,40 +47,44 @@ class TaskCard extends StatelessWidget {
Widget build(BuildContext context) {
final isMobile = MediaQuery.sizeOf(context).width < Breakpoints.mobile;
if (isMobile) {
final card = TaskCardMobile(
task: task,
selected: selected,
batchMode: batchMode,
selectedInBatch: selectedInBatch,
onPressed: onPressed,
onToggleBatch: onToggleBatch,
onPause: onPause,
onResume: onResume,
onDelete: onDelete,
onOpen: onOpen,
onReveal: onReveal,
listeningForUpdate: listeningForUpdate,
);
final actions = contextActions;
return actions == null ? card : TaskContextMenu(task: task, actions: actions, child: card);
}
final card = TaskCardDesktop(
task: task,
selected: selected,
batchMode: batchMode,
selectedInBatch: selectedInBatch,
onPressed: onPressed,
onToggleBatch: onToggleBatch,
onPause: onPause,
onResume: onResume,
onDelete: onDelete,
onOpen: onOpen,
onReveal: onReveal,
listeningForUpdate: listeningForUpdate,
);
final card = isMobile
? TaskCardMobile(
task: task,
expanded: expanded,
selected: selected,
batchMode: batchMode,
selectedInBatch: selectedInBatch,
onPressed: onPressed,
onToggleBatch: onToggleBatch,
onPause: task.canPause ? onPause : null,
onResume: task.canResume ? onResume : null,
onDelete: onDelete,
onOpen: onOpen,
onReveal: onReveal,
listeningForUpdate: listeningForUpdate,
)
: TaskCardDesktop(
task: task,
expanded: expanded,
selected: selected,
batchMode: batchMode,
selectedInBatch: selectedInBatch,
onPressed: onPressed,
onToggleBatch: onToggleBatch,
onPause: task.canPause ? onPause : null,
onResume: task.canResume ? onResume : null,
onDelete: onDelete,
onOpen: onOpen,
onReveal: onReveal,
listeningForUpdate: listeningForUpdate,
);
final actions = contextActions;
return actions == null ? card : TaskContextMenu(task: task, actions: actions, child: card);
return Padding(
padding: EdgeInsetsDirectional.only(start: nested ? AppDesignTokens.space24 : 0),
child: Semantics(
expanded: task.isGroup ? expanded : null,
child: actions == null ? card : TaskContextMenu(task: task, actions: actions, child: card),
),
);
}
}
@@ -14,6 +14,7 @@ class TaskCardDesktop extends StatefulWidget {
const TaskCardDesktop({
super.key,
required this.task,
this.expanded = false,
required this.selected,
required this.batchMode,
required this.selectedInBatch,
@@ -28,6 +29,7 @@ class TaskCardDesktop extends StatefulWidget {
});
final TaskRecord task;
final bool expanded;
final bool selected;
final bool batchMode;
final bool selectedInBatch;
@@ -64,7 +66,7 @@ class _TaskCardDesktopState extends State<TaskCardDesktop> {
_lastTapTime = isDoubleTap ? null : now;
_lastTapPosition = isDoubleTap ? null : details.globalPosition;
widget.onPressed();
if (isDoubleTap) {
if (isDoubleTap && !widget.task.isGroup) {
widget.onOpen?.call();
}
}
@@ -134,7 +136,7 @@ class _TaskCardDesktopState extends State<TaskCardDesktop> {
)
: const SizedBox.shrink(),
),
TaskCardIcon(task: task),
TaskCardIcon(task: task, expanded: widget.expanded),
const SizedBox(width: 16),
Expanded(
child: SizedBox(
@@ -234,15 +236,21 @@ class _ProgressContent extends StatelessWidget {
}
List<Widget> _buildActionButtons(TaskRecord task, AppPalette palette, bool isDark) {
final actions = switch (task.status) {
TaskStatus.downloading => [(Icons.pause, onPause), (Icons.close, onDelete)],
TaskStatus.paused => [(Icons.play_arrow, onResume), (Icons.close, onDelete)],
TaskStatus.failed => [(Icons.refresh, onResume), (Icons.delete_outline, onDelete)],
TaskStatus.completed => [
if (AppWindowChrome.isDesktopWindow) (Icons.folder_open_outlined, null),
(Icons.delete_outline, onDelete),
],
};
final actions = task.isGroup && task.status != TaskStatus.completed
? [
if (task.canPause) (Icons.pause, onPause),
if (task.canResume) (Icons.play_arrow, onResume),
(Icons.close, onDelete),
]
: switch (task.status) {
TaskStatus.downloading => [(Icons.pause, onPause), (Icons.close, onDelete)],
TaskStatus.paused => [(Icons.play_arrow, onResume), (Icons.close, onDelete)],
TaskStatus.failed => [(Icons.refresh, onResume), (Icons.delete_outline, onDelete)],
TaskStatus.completed => [
if (AppWindowChrome.isDesktopWindow) (Icons.folder_open_outlined, null),
(Icons.delete_outline, onDelete),
],
};
return actions
.map(
@@ -13,6 +13,7 @@ class TaskCardMobile extends StatefulWidget {
const TaskCardMobile({
super.key,
required this.task,
this.expanded = false,
required this.selected,
required this.batchMode,
required this.selectedInBatch,
@@ -27,6 +28,7 @@ class TaskCardMobile extends StatefulWidget {
});
final TaskRecord task;
final bool expanded;
final bool selected;
final bool batchMode;
final bool selectedInBatch;
@@ -62,7 +64,7 @@ class _TaskCardMobileState extends State<TaskCardMobile> {
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onPressed,
onDoubleTap: !widget.batchMode && task.status == TaskStatus.completed ? widget.onOpen : null,
onDoubleTap: !widget.batchMode && !task.isGroup && task.status == TaskStatus.completed ? widget.onOpen : null,
child: Container(
key: const ValueKey('task-card-mobile-surface'),
decoration: BoxDecoration(
@@ -117,7 +119,7 @@ class _TaskCardMobileState extends State<TaskCardMobile> {
)
: const SizedBox.shrink(),
),
TaskCardIcon(task: task),
TaskCardIcon(task: task, expanded: widget.expanded),
const SizedBox(width: 12),
Expanded(
child: Text(
@@ -168,15 +170,21 @@ class _TaskCardMobileState extends State<TaskCardMobile> {
}
List<Widget> _buildActionButtons(TaskRecord task, AppPalette palette, bool isDark) {
final actions = switch (task.status) {
TaskStatus.downloading => [(Icons.pause, widget.onPause), (Icons.close, widget.onDelete)],
TaskStatus.paused => [(Icons.play_arrow, widget.onResume), (Icons.close, widget.onDelete)],
TaskStatus.failed => [(Icons.refresh, widget.onResume), (Icons.delete_outline, widget.onDelete)],
TaskStatus.completed => [
if (AppWindowChrome.isDesktopWindow) (Icons.folder_open_outlined, widget.onReveal),
(Icons.delete_outline, widget.onDelete),
],
};
final actions = task.isGroup && task.status != TaskStatus.completed
? [
if (task.canPause) (Icons.pause, widget.onPause),
if (task.canResume) (Icons.play_arrow, widget.onResume),
(Icons.close, widget.onDelete),
]
: switch (task.status) {
TaskStatus.downloading => [(Icons.pause, widget.onPause), (Icons.close, widget.onDelete)],
TaskStatus.paused => [(Icons.play_arrow, widget.onResume), (Icons.close, widget.onDelete)],
TaskStatus.failed => [(Icons.refresh, widget.onResume), (Icons.delete_outline, widget.onDelete)],
TaskStatus.completed => [
if (AppWindowChrome.isDesktopWindow) (Icons.folder_open_outlined, widget.onReveal),
(Icons.delete_outline, widget.onDelete),
],
};
return actions
.map(
@@ -46,25 +46,35 @@ class TaskUpdateListeningIndicator extends StatelessWidget {
}
class TaskCardIcon extends StatelessWidget {
const TaskCardIcon({super.key, required this.task});
const TaskCardIcon({super.key, required this.task, this.expanded = false});
final TaskRecord task;
final bool expanded;
@override
Widget build(BuildContext context) {
final palette = AppPalette.of(context);
return SizedBox(
width: 32,
height: 32,
child: ClipRRect(
borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius),
clipBehavior: Clip.hardEdge,
child: ColoredBox(
color: palette.taskCardIconBg,
child: Center(child: Icon(task.icon, size: 20, color: palette.textSecondary)),
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (task.isGroup) ...[
Icon(expanded ? Icons.expand_more : Icons.chevron_right, size: 18, color: palette.textSecondary),
const SizedBox(width: AppDesignTokens.space4),
],
SizedBox(
width: 32,
height: 32,
child: ClipRRect(
borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius),
clipBehavior: Clip.hardEdge,
child: ColoredBox(
color: palette.taskCardIconBg,
child: Center(child: Icon(task.icon, size: 20, color: palette.textSecondary)),
),
),
),
),
],
);
}
}
@@ -69,14 +69,14 @@ class TaskContextMenu extends StatelessWidget {
shad.MenuButton(
key: const ValueKey('task-context-resume'),
leading: const Icon(Icons.play_arrow, size: 16),
enabled: task.status == TaskStatus.paused || task.status == TaskStatus.failed,
enabled: task.canResume,
onPressed: (_) => actions.onResume(),
child: Text(context.l10n.continueAction),
),
shad.MenuButton(
key: const ValueKey('task-context-pause'),
leading: const Icon(Icons.pause, size: 16),
enabled: task.status == TaskStatus.downloading,
enabled: task.canPause,
onPressed: (_) => actions.onPause(),
child: Text(context.l10n.pause),
),
@@ -5,7 +5,13 @@ import '../../../../shared/theme/app_palette.dart';
import '../../../../shared/theme/app_design_tokens.dart';
import '../../../../l10n/l10n.dart';
Future<bool?> showTaskDeleteDialog(BuildContext context, {required int taskCount, required bool keepFiles}) async {
Future<bool?> showTaskDeleteDialog(
BuildContext context, {
required int taskCount,
required bool keepFiles,
int? memberCount,
int? fileCount,
}) async {
var currentKeepFiles = keepFiles;
final dialog = const DialogOverlayHandler().show<bool>(
context: context,
@@ -32,34 +38,46 @@ Future<bool?> showTaskDeleteDialog(BuildContext context, {required int taskCount
title: Text(dialogContext.l10n.deleteTasksTitle(taskCount)),
content: SizedBox(
width: contentWidth,
child: AnimatedContainer(
key: const ValueKey('task-delete-keep-files-option'),
duration: const Duration(milliseconds: 160),
padding: const EdgeInsets.symmetric(
horizontal: AppDesignTokens.space16,
vertical: AppDesignTokens.space12,
),
decoration: BoxDecoration(
color: currentKeepFiles ? palette.brandSoft : palette.surfaceSoft,
border: Border.all(color: currentKeepFiles ? palette.brand.withValues(alpha: 0.45) : palette.border),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Expanded(
child: Text(
dialogContext.l10n.deleteTaskTip,
style: TextStyle(color: palette.textPrimary, fontSize: 14, fontWeight: FontWeight.w500),
),
),
const SizedBox(width: AppDesignTokens.space16),
Switch(
key: const ValueKey('task-delete-keep-files-switch'),
value: currentKeepFiles,
onChanged: (value) => setDialogState(() => currentKeepFiles = value),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (memberCount != null) ...[
Text(dialogContext.l10n.taskGroupDeleteImpact(memberCount, fileCount ?? 0)),
const SizedBox(height: AppDesignTokens.space16),
],
),
AnimatedContainer(
key: const ValueKey('task-delete-keep-files-option'),
duration: const Duration(milliseconds: 160),
padding: const EdgeInsets.symmetric(
horizontal: AppDesignTokens.space16,
vertical: AppDesignTokens.space12,
),
decoration: BoxDecoration(
color: currentKeepFiles ? palette.brandSoft : palette.surfaceSoft,
border: Border.all(
color: currentKeepFiles ? palette.brand.withValues(alpha: 0.45) : palette.border,
),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Expanded(
child: Text(
dialogContext.l10n.deleteTaskTip,
style: TextStyle(color: palette.textPrimary, fontSize: 14, fontWeight: FontWeight.w500),
),
),
const SizedBox(width: AppDesignTokens.space16),
Switch(
key: const ValueKey('task-delete-keep-files-switch'),
value: currentKeepFiles,
onChanged: (value) => setDialogState(() => currentKeepFiles = value),
),
],
),
),
],
),
),
actions: [
@@ -121,7 +121,9 @@ class _TaskDetailsViewState extends ConsumerState<TaskDetailsView> {
final contentPadding = widget.mobile
? AppDesignTokens.taskDetailsMobilePadding
: AppDesignTokens.taskDetailsDesktopPadding;
final runtimeStatus = _tabIndex == 1 ? ref.watch(taskRuntimeStatusProvider(widget.task.id)).value : null;
final runtimeStatus = _tabIndex == 1 && !widget.task.isGroup
? ref.watch(taskRuntimeStatusProvider(widget.task.id)).value
: null;
final content = Column(
children: [
Container(
@@ -148,12 +150,14 @@ class _TaskDetailsViewState extends ConsumerState<TaskDetailsView> {
active: _tabIndex == 1,
onTap: () => setState(() => _tabIndex = 1),
),
const SizedBox(width: 24),
_DrawerTabButton(
label: context.l10n.taskDetailsStatsTab,
active: _tabIndex == 2,
onTap: () => setState(() => _tabIndex = 2),
),
if (!widget.task.isGroup) ...[
const SizedBox(width: 24),
_DrawerTabButton(
label: context.l10n.taskDetailsStatsTab,
active: _tabIndex == 2,
onTap: () => setState(() => _tabIndex = 2),
),
],
],
),
),
@@ -340,7 +344,7 @@ class _TaskInfoTab extends StatelessWidget {
onCancel: onCancelEditUrl,
onSave: onSaveUrl,
)
else
else if (!task.isGroup)
_CopyableInfoBlock(
label: context.l10n.downloadLink,
value: task.url,
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart' show Icons;
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
@@ -7,6 +8,7 @@ import '../../../../l10n/l10n.dart';
import '../../../../shared/widgets/app_tooltip.dart';
import '../../../../util/util.dart';
import '../../domain/task_record.dart';
import '../../application/tasks_controller.dart';
import 'task_file_manager.dart';
Future<void> browseTaskFiles(BuildContext context, TaskRecord task, {required bool mobile}) async {
@@ -49,7 +51,16 @@ Future<void> showTaskFileBrowserDialog(BuildContext context, TaskRecord task) as
content: SizedBox(
width: dialogWidth,
height: dialogHeight,
child: TaskFileManagerView(task: task),
child: Consumer(
builder: (context, ref, _) {
final state = ref.watch(tasksControllerProvider).value;
final current = state?.findTask(task.id);
if (state != null && current == null) {
return Center(child: Text(context.l10n.taskNotFound));
}
return TaskFileManagerView(task: current ?? task);
},
),
),
);
},
@@ -120,14 +120,19 @@ class _TaskFileManagerViewState extends State<TaskFileManagerView> {
}
String _localPath(_TaskFileEntry entry) {
if (entry.localPath != null) return entry.localPath!;
if (!widget.task.isFolder && _index.fileCount == 1) return widget.task.storagePath;
final segments = entry.fullPath.split('/').where((part) => part.isNotEmpty);
return path.joinAll([widget.task.storagePath, ...segments]);
}
String _accessUrl(_TaskFileEntry entry) {
final encodedPath = entry.fullPath.split('/').where((part) => part.isNotEmpty).map(Uri.encodeComponent).join('/');
return api.join('/fs/tasks/${Uri.encodeComponent(widget.task.id)}/$encodedPath');
final encodedPath = (entry.source?.accessPath ?? entry.fullPath)
.split('/')
.where((part) => part.isNotEmpty)
.map(Uri.encodeComponent)
.join('/');
return api.join('/fs/tasks/${Uri.encodeComponent(entry.source?.taskId ?? widget.task.id)}/$encodedPath');
}
}
@@ -304,7 +309,7 @@ class _FileActionButton extends StatelessWidget {
class _TaskFileDirectoryIndex {
_TaskFileDirectoryIndex(TaskRecord task) {
final sourceFiles = task.files.isEmpty
final sourceFiles = task.files.isEmpty && !task.isGroup
? [TaskFileNode(path: '/', name: task.name, sizeBytes: task.totalBytes ?? 0)]
: task.files;
fileCount = sourceFiles.length;
@@ -313,8 +318,18 @@ class _TaskFileDirectoryIndex {
final directory = _normalizeDirectory(file.path);
final name = singleFile ? path.basename(task.storagePath) : file.name;
final fullPath = directory == '/' ? '/$name' : '$directory/$name';
_add(directory, _TaskFileEntry(directory: false, fullPath: fullPath, name: name, size: file.sizeBytes));
_addParentDirectories(directory);
_add(
directory,
_TaskFileEntry(
directory: false,
fullPath: fullPath,
name: name,
size: file.sizeBytes,
source: file,
localPath: file.localPath,
),
);
_addParentDirectories(directory, file.localPath == null ? null : path.dirname(file.localPath!));
}
for (final entries in _entries.values) {
entries.sort((left, right) {
@@ -331,15 +346,19 @@ class _TaskFileDirectoryIndex {
List<_TaskFileEntry> entries(String directory) => _entries[directory] ?? const [];
void _addParentDirectories(String directory) {
void _addParentDirectories(String directory, String? localPath) {
var current = directory;
while (current != '/') {
final parent = path.posix.dirname(current);
final normalizedParent = parent == '.' ? '/' : parent;
final name = path.posix.basename(current);
_add(normalizedParent, _TaskFileEntry(directory: true, fullPath: current, name: name, size: 0));
_add(
normalizedParent,
_TaskFileEntry(directory: true, fullPath: current, name: name, size: 0, localPath: localPath),
);
_entries.putIfAbsent(current, () => []);
current = normalizedParent;
if (localPath != null) localPath = path.dirname(localPath);
}
}
@@ -350,12 +369,21 @@ class _TaskFileDirectoryIndex {
}
class _TaskFileEntry {
const _TaskFileEntry({required this.directory, required this.fullPath, required this.name, required this.size});
const _TaskFileEntry({
required this.directory,
required this.fullPath,
required this.name,
required this.size,
this.source,
this.localPath,
});
final bool directory;
final String fullPath;
final String name;
final int size;
final TaskFileNode? source;
final String? localPath;
}
String _normalizeDirectory(String value) {
@@ -42,6 +42,13 @@ class _TaskFileTreeState extends State<TaskFileTree> {
Widget build(BuildContext context) {
final progressByIndex = {
for (final file in widget.runtimeStatus?.files ?? const <FileRuntimeStatus>[]) file.index: file,
if (widget.task.isGroup)
for (final (index, file) in widget.task.files.indexed)
file.resourceIndex ?? index: FileRuntimeStatus(
index: file.resourceIndex ?? index,
size: file.sizeBytes,
downloaded: file.downloadedBytes ?? 0,
),
};
return Column(
@@ -179,7 +186,17 @@ class _FileDownloadProgress extends StatelessWidget {
@override
Widget build(BuildContext context) {
final palette = AppPalette.of(context);
final taskStateIcon = switch (taskStatus) {
final statuses = node.leafItems.map((item) => item.data.status).whereType<TaskStatus>().toSet();
final status = statuses.isEmpty
? taskStatus
: statuses.contains(TaskStatus.downloading)
? TaskStatus.downloading
: statuses.contains(TaskStatus.failed)
? TaskStatus.failed
: statuses.contains(TaskStatus.paused)
? TaskStatus.paused
: TaskStatus.completed;
final taskStateIcon = switch (status) {
TaskStatus.paused => Semantics(
label: context.l10n.pause,
child: Icon(Icons.pause_circle_filled, size: 16, color: palette.textMuted),
+5 -2
View File
@@ -372,7 +372,9 @@
"torrentDataEmpty": "بيانات الملف المُتعذّب فارغة",
"torrentPathUnavailable": "غير قادر على الوصول إلى طريق الملف المُتعذّب",
"selectAtLeastOneFile": "اختار ملف واحد على الأقل",
"resolvedFiles": "الملفات المبتغاة",
"resolvedFiles": "قائمة الملفات",
"waitingToResolve": "بانتظار التحليل",
"resolvingResource": "جارٍ تحليل المورد",
"resolvedFileCount": "{count}",
"resolvedCategoryDirectory": "سيتم الحفظ في {path}",
"resourceHasNoFiles": "ولا توجد في هذا المورد قائمة ملف قابلة للاختيار",
@@ -477,5 +479,6 @@
"trackerSubscriptionUpdateStatus": "حالة التحديث",
"trackerSubscriptionTrackerCount": "عدد المتتبعات",
"trackerSubscriptionNotUpdated": "لم يُحدّث بعد",
"actions": "الإجراءات"
"actions": "الإجراءات",
"taskGroupDeleteImpact": "سيؤثر على {taskCount} من مهام التنزيل و{fileCount} من الملفات."
}
+5 -2
View File
@@ -386,7 +386,9 @@
"torrentDataEmpty": "Torrent fayl məlumatı boşdur",
"torrentPathUnavailable": "Torrent fayl yoluna giriş mümkün olmadı",
"selectAtLeastOneFile": "Ən azı bir fayl seçin",
"resolvedFiles": "Tapılmış Fayllar",
"resolvedFiles": "Fayl siyahısı",
"waitingToResolve": "Təhlil gözlənilir",
"resolvingResource": "Resurs təhlil edilir",
"resolvedFileCount": "{count} fayl tapıldı",
"resolvedCategoryDirectory": "{path} qovluğuna saxlanılacaq",
"resourceHasNoFiles": "Bu resursda seçilə bilən fayl siyahısı yoxdur",
@@ -661,5 +663,6 @@
"trackerSubscriptionUpdateStatus": "Yeniləmə statusu",
"trackerSubscriptionTrackerCount": "Tracker sayı",
"trackerSubscriptionNotUpdated": "Hələ yenilənməyib",
"actions": "Əməliyyatlar"
"actions": "Əməliyyatlar",
"taskGroupDeleteImpact": "{taskCount} endirməyə və {fileCount} fayla təsir edir."
}
+5 -2
View File
@@ -372,7 +372,9 @@
"torrentDataEmpty": "El fitxer torrent de dades és buit",
"torrentPathUnavailable": "No s' ha pogut accedir al camí del fitxer torrent",
"selectAtLeastOneFile": "Seleccioneu com a mínim un fitxer",
"resolvedFiles": "Fitxers resolts",
"resolvedFiles": "Llista de fitxers",
"waitingToResolve": "Esperant l’anàlisi",
"resolvingResource": "S’està analitzant el recurs",
"resolvedFileCount": "{count}",
"resolvedCategoryDirectory": "Es desarà a {path}",
"resourceHasNoFiles": "El recurs no té cap llista de fitxers per seleccionar",
@@ -477,5 +479,6 @@
"trackerSubscriptionUpdateStatus": "Estat de l’actualització",
"trackerSubscriptionTrackerCount": "Nombre de trackers",
"trackerSubscriptionNotUpdated": "Sense actualitzar",
"actions": "Accions"
"actions": "Accions",
"taskGroupDeleteImpact": "Afecta {taskCount} baixades i {fileCount} fitxers."
}
+5 -2
View File
@@ -372,7 +372,9 @@
"torrentDataEmpty": "Torrent-Dateidaten sind leer",
"torrentPathUnavailable": "Auf den Torrent-Dateipfad kann nicht zugegriffen werden",
"selectAtLeastOneFile": "Wählen Sie mindestens eine Datei aus",
"resolvedFiles": "Aufgelöste Dateien",
"resolvedFiles": "Dateiliste",
"waitingToResolve": "Warten auf Auflösung",
"resolvingResource": "Ressource wird aufgelöst",
"resolvedFileCount": "{count}-Dateien behoben",
"resolvedCategoryDirectory": "Wird in {path} gespeichert",
"resourceHasNoFiles": "Die Ressource verfügt über keine auswählbare Dateiliste",
@@ -477,5 +479,6 @@
"trackerSubscriptionUpdateStatus": "Aktualisierungsstatus",
"trackerSubscriptionTrackerCount": "Tracker-Anzahl",
"trackerSubscriptionNotUpdated": "Noch nicht aktualisiert",
"actions": "Aktionen"
"actions": "Aktionen",
"taskGroupDeleteImpact": "Betrifft {taskCount} Downloads und {fileCount} Dateien."
}
+15 -2
View File
@@ -406,7 +406,9 @@
"torrentDataEmpty": "Torrent file data is empty",
"torrentPathUnavailable": "Unable to access the torrent file path",
"selectAtLeastOneFile": "Select at least one file",
"resolvedFiles": "Resolved Files",
"resolvedFiles": "File List",
"waitingToResolve": "Waiting to resolve",
"resolvingResource": "Resolving resource",
"resolvedFileCount": "{count, plural, =1{1 file resolved} other{{count} files resolved}}",
"resolvedCategoryDirectory": "Will be saved to {path}",
"@resolvedCategoryDirectory": {
@@ -704,5 +706,16 @@
"trackerSubscriptionUpdateStatus": "Update status",
"trackerSubscriptionTrackerCount": "Tracker count",
"trackerSubscriptionNotUpdated": "Not updated",
"actions": "Actions"
"actions": "Actions",
"taskGroupDeleteImpact": "Affects {taskCount} downloads and {fileCount} files.",
"@taskGroupDeleteImpact": {
"placeholders": {
"taskCount": {
"type": "int"
},
"fileCount": {
"type": "int"
}
}
}
}
+5 -2
View File
@@ -372,7 +372,9 @@
"torrentDataEmpty": "Los datos de archivos Torrent están vacíos",
"torrentPathUnavailable": "Incapaz de acceder a la ruta del archivo torrent",
"selectAtLeastOneFile": "Seleccione al menos un archivo",
"resolvedFiles": "Archivos resueltos",
"resolvedFiles": "Lista de archivos",
"waitingToResolve": "Esperando resolución",
"resolvingResource": "Resolviendo recurso",
"resolvedFileCount": "{count} resueltos",
"resolvedCategoryDirectory": "Se guardará en {path}",
"resourceHasNoFiles": "El recurso no tiene lista de archivos seleccionable",
@@ -477,5 +479,6 @@
"trackerSubscriptionUpdateStatus": "Estado de actualización",
"trackerSubscriptionTrackerCount": "Número de trackers",
"trackerSubscriptionNotUpdated": "Sin actualizar",
"actions": "Acciones"
"actions": "Acciones",
"taskGroupDeleteImpact": "Afecta a {taskCount} descargas y {fileCount} archivos."
}
+5 -2
View File
@@ -372,7 +372,9 @@
"torrentDataEmpty": "داده های فایل تورنت خالی است",
"torrentPathUnavailable": "امکان دسترسی به مسیر فایل تورنت",
"selectAtLeastOneFile": "حداقل یک فایل را انتخاب کنید",
"resolvedFiles": "فایل های حل شده",
"resolvedFiles": "فهرست فایل‌ها",
"waitingToResolve": "در انتظار پردازش",
"resolvingResource": "در حال پردازش منبع",
"resolvedFileCount": "{count} فایل شناسایی شد",
"resolvedCategoryDirectory": "در {path} ذخیره می‌شود",
"resourceHasNoFiles": "منبع هیچ لیست فایل قابل انتخاب ندارد",
@@ -477,5 +479,6 @@
"trackerSubscriptionUpdateStatus": "وضعیت به‌روزرسانی",
"trackerSubscriptionTrackerCount": "تعداد ردیاب‌ها",
"trackerSubscriptionNotUpdated": "هنوز به‌روزرسانی نشده",
"actions": "عملیات"
"actions": "عملیات",
"taskGroupDeleteImpact": "بر {taskCount} بارگیری و {fileCount} پرونده تأثیر می‌گذارد."
}
+5 -2
View File
@@ -372,7 +372,9 @@
"torrentDataEmpty": "Les données du fichier Torrent sont vides",
"torrentPathUnavailable": "Impossible d'accéder au chemin de fichier torrent",
"selectAtLeastOneFile": "Sélectionnez au moins un fichier",
"resolvedFiles": "Fichiers résolus",
"resolvedFiles": "Liste des fichiers",
"waitingToResolve": "En attente d’analyse",
"resolvingResource": "Analyse de la ressource",
"resolvedFileCount": "{count} résolus",
"resolvedCategoryDirectory": "Sera enregistré dans {path}",
"resourceHasNoFiles": "La ressource n'a pas de liste de fichiers sélectionnable",
@@ -477,5 +479,6 @@
"trackerSubscriptionUpdateStatus": "État de mise à jour",
"trackerSubscriptionTrackerCount": "Nombre de trackers",
"trackerSubscriptionNotUpdated": "Pas encore mis à jour",
"actions": "Actions"
"actions": "Actions",
"taskGroupDeleteImpact": "Concerne {taskCount} téléchargements et {fileCount} fichiers."
}
+5 -2
View File
@@ -372,7 +372,9 @@
"torrentDataEmpty": "A torrent fájl adatai üresek",
"torrentPathUnavailable": "Nem érhető el a torrent fájl elérési útja",
"selectAtLeastOneFile": "Válasszon ki legalább egy fájlt",
"resolvedFiles": "Feloldott fájlok",
"resolvedFiles": "Fájllista",
"waitingToResolve": "Feldolgozásra vár",
"resolvingResource": "Erőforrás feldolgozása",
"resolvedFileCount": "{count} fájlok megoldva",
"resolvedCategoryDirectory": "Ide lesz mentve: {path}",
"resourceHasNoFiles": "Az erőforrásnak nincs kiválasztható fájllistája",
@@ -477,5 +479,6 @@
"trackerSubscriptionUpdateStatus": "Frissítés állapota",
"trackerSubscriptionTrackerCount": "Trackerek száma",
"trackerSubscriptionNotUpdated": "Még nem frissült",
"actions": "Műveletek"
"actions": "Műveletek",
"taskGroupDeleteImpact": "{taskCount} letöltést és {fileCount} fájlt érint."
}
+5 -2
View File
@@ -372,7 +372,9 @@
"torrentDataEmpty": "Data berkas Torrent kosong",
"torrentPathUnavailable": "Tak bisa mengakses path berkas torrent",
"selectAtLeastOneFile": "Pilih paling tidak satu berkas",
"resolvedFiles": "Berkas Terpecahkan",
"resolvedFiles": "Daftar berkas",
"waitingToResolve": "Menunggu pemrosesan",
"resolvingResource": "Memproses sumber daya",
"resolvedFileCount": "{count} terselesaikan",
"resolvedCategoryDirectory": "Akan disimpan ke {path}",
"resourceHasNoFiles": "Sumber daya tidak memiliki daftar berkas yang dapat dipilih",
@@ -477,5 +479,6 @@
"trackerSubscriptionUpdateStatus": "Status pembaruan",
"trackerSubscriptionTrackerCount": "Jumlah tracker",
"trackerSubscriptionNotUpdated": "Belum diperbarui",
"actions": "Tindakan"
"actions": "Tindakan",
"taskGroupDeleteImpact": "Memengaruhi {taskCount} unduhan dan {fileCount} berkas."
}
+5 -2
View File
@@ -372,7 +372,9 @@
"torrentDataEmpty": "I dati dei file Torrent sono vuoti",
"torrentPathUnavailable": "Non è possibile accedere al percorso file torrent",
"selectAtLeastOneFile": "Selezionare almeno un file",
"resolvedFiles": "File recuperati",
"resolvedFiles": "Elenco dei file",
"waitingToResolve": "In attesa di analisi",
"resolvingResource": "Analisi della risorsa",
"resolvedFileCount": "{count} risolti",
"resolvedCategoryDirectory": "Verrà salvato in {path}",
"resourceHasNoFiles": "La risorsa non ha un elenco di file selezionabile",
@@ -477,5 +479,6 @@
"trackerSubscriptionUpdateStatus": "Stato aggiornamento",
"trackerSubscriptionTrackerCount": "Numero di tracker",
"trackerSubscriptionNotUpdated": "Non ancora aggiornato",
"actions": "Azioni"
"actions": "Azioni",
"taskGroupDeleteImpact": "Riguarda {taskCount} download e {fileCount} file."
}
+5 -2
View File
@@ -372,7 +372,9 @@
"torrentDataEmpty": "トレントファイルデータが空です",
"torrentPathUnavailable": "トレントファイルパスにアクセスできない",
"selectAtLeastOneFile": "少なくとも1つのファイルを選択します",
"resolvedFiles": "解決されたファイル",
"resolvedFiles": "ファイル一覧",
"waitingToResolve": "解析待ち",
"resolvingResource": "リソースを解析中",
"resolvedFileCount": "{count} ファイルが解決しました",
"resolvedCategoryDirectory": "{path} に保存されます",
"resourceHasNoFiles": "リソースには、選択可能なファイルリストはありません",
@@ -477,5 +479,6 @@
"trackerSubscriptionUpdateStatus": "更新状況",
"trackerSubscriptionTrackerCount": "Tracker 数",
"trackerSubscriptionNotUpdated": "未更新",
"actions": "操作"
"actions": "操作",
"taskGroupDeleteImpact": "{taskCount} 件のダウンロードと {fileCount} 個のファイルが対象です。"
}
+5 -2
View File
@@ -372,7 +372,9 @@
"torrentDataEmpty": "Torrent 파일 데이터가 비어 있습니다",
"torrentPathUnavailable": "토렌트 파일 경로에 액세스 할 수 없습니다",
"selectAtLeastOneFile": "최소 1개의 파일을 선택하십시오",
"resolvedFiles": "해결된 파일",
"resolvedFiles": "파일 목록",
"waitingToResolve": "분석 대기 중",
"resolvingResource": "리소스 분석 중",
"resolvedFileCount": "{count} 파일 해결",
"resolvedCategoryDirectory": "{path}에 저장됩니다",
"resourceHasNoFiles": "리소스에는 선택 가능한 파일 목록이 없습니다",
@@ -477,5 +479,6 @@
"trackerSubscriptionUpdateStatus": "업데이트 상태",
"trackerSubscriptionTrackerCount": "트래커 수",
"trackerSubscriptionNotUpdated": "아직 업데이트되지 않음",
"actions": "작업"
"actions": "작업",
"taskGroupDeleteImpact": "다운로드 {taskCount}개와 파일 {fileCount}개에 영향을 줍니다."
}
+5 -2
View File
@@ -372,7 +372,9 @@
"torrentDataEmpty": "Dane pliku torrenta są puste",
"torrentPathUnavailable": "Nie można uzyskać dostępu do ścieżki pliku torrenta",
"selectAtLeastOneFile": "Wybierz co najmniej jeden plik",
"resolvedFiles": "Rozwiązywane pliki",
"resolvedFiles": "Lista plików",
"waitingToResolve": "Oczekiwanie na analizę",
"resolvingResource": "Analizowanie zasobu",
"resolvedFileCount": "{count} plików rozwiązanych",
"resolvedCategoryDirectory": "Zostanie zapisane w {path}",
"resourceHasNoFiles": "Zasób nie ma listy plików do wyboru",
@@ -477,5 +479,6 @@
"trackerSubscriptionUpdateStatus": "Stan aktualizacji",
"trackerSubscriptionTrackerCount": "Liczba trackerów",
"trackerSubscriptionNotUpdated": "Jeszcze nie zaktualizowano",
"actions": "Działania"
"actions": "Działania",
"taskGroupDeleteImpact": "Dotyczy {taskCount} pobrań i {fileCount} plików."
}
+5 -2
View File
@@ -372,7 +372,9 @@
"torrentDataEmpty": "Os dados do ficheiro Torrent estão em branco",
"torrentPathUnavailable": "Não foi possível aceder ao local do ficheiro torrent",
"selectAtLeastOneFile": "Selecione pelo menos um arquivo",
"resolvedFiles": "Ficheiros Resolvidos",
"resolvedFiles": "Lista de arquivos",
"waitingToResolve": "Aguardando resolução",
"resolvingResource": "Resolvendo recurso",
"resolvedFileCount": "{count} ficheiros resolvidos",
"resolvedCategoryDirectory": "Será salvo em {path}",
"resourceHasNoFiles": "O recurso não tem lista de arquivos selecionáveis",
@@ -477,5 +479,6 @@
"trackerSubscriptionUpdateStatus": "Estado da atualização",
"trackerSubscriptionTrackerCount": "Número de trackers",
"trackerSubscriptionNotUpdated": "Ainda não atualizado",
"actions": "Ações"
"actions": "Ações",
"taskGroupDeleteImpact": "Afeta {taskCount} downloads e {fileCount} arquivos."
}
+5 -2
View File
@@ -372,7 +372,9 @@
"torrentDataEmpty": "Данные торрент-файлов пусты",
"torrentPathUnavailable": "Невозможность доступа к торрент-файлу",
"selectAtLeastOneFile": "Выберите хотя бы один файл",
"resolvedFiles": "Решенные файлы",
"resolvedFiles": "Список файлов",
"waitingToResolve": "Ожидание анализа",
"resolvingResource": "Анализ ресурса",
"resolvedFileCount": "{count} файлов, разрешенных",
"resolvedCategoryDirectory": "Будет сохранено в {path}",
"resourceHasNoFiles": "Ресурс не имеет выбираемого списка файлов",
@@ -477,5 +479,6 @@
"trackerSubscriptionUpdateStatus": "Статус обновления",
"trackerSubscriptionTrackerCount": "Число трекеров",
"trackerSubscriptionNotUpdated": "Ещё не обновлено",
"actions": "Действия"
"actions": "Действия",
"taskGroupDeleteImpact": "Затронет {taskCount} загрузок и {fileCount} файлов."
}
+5 -2
View File
@@ -395,7 +395,9 @@
"torrentDataEmpty": "Údaje torrent súboru sú prázdne",
"torrentPathUnavailable": "Nie je možné pristúpiť k ceste torrent súboru",
"selectAtLeastOneFile": "Vyberte aspoň jeden súbor",
"resolvedFiles": "Vyriešené súbory",
"resolvedFiles": "Zoznam súborov",
"waitingToResolve": "Čaká na spracovanie",
"resolvingResource": "Spracovanie zdroja",
"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",
@@ -678,5 +680,6 @@
"trackerSubscriptionUpdateStatus": "Stav aktualizácie",
"trackerSubscriptionTrackerCount": "Počet trackerov",
"trackerSubscriptionNotUpdated": "Zatiaľ neaktualizované",
"actions": "Akcie"
"actions": "Akcie",
"taskGroupDeleteImpact": "Ovplyvní {taskCount} sťahovaní a {fileCount} súborov."
}
+5 -2
View File
@@ -372,7 +372,9 @@
"torrentDataEmpty": "டொரண்ட் கோப்பு தரவு காலியாக உள்ளது",
"torrentPathUnavailable": "டொரண்ட் கோப்பு பாதையை அணுக முடியவில்லை",
"selectAtLeastOneFile": "குறைந்தது ஒரு கோப்பையாவது தேர்ந்தெடுக்கவும்",
"resolvedFiles": "தீர்க்கப்பட்ட கோப்புகள்",
"resolvedFiles": "கோப்புப் பட்டியல்",
"waitingToResolve": "பகுப்பாய்விற்காகக் காத்திருக்கிறது",
"resolvingResource": "வளத்தைப் பகுப்பாய்வு செய்கிறது",
"resolvedFileCount": "{count} கோப்புகள் தீர்க்கப்பட்டன",
"resolvedCategoryDirectory": "{path} இல் சேமிக்கப்படும்",
"resourceHasNoFiles": "வளத்தில் தேர்ந்தெடுக்கக்கூடிய கோப்பு பட்டியல் இல்லை",
@@ -477,5 +479,6 @@
"trackerSubscriptionUpdateStatus": "புதுப்பிப்பு நிலை",
"trackerSubscriptionTrackerCount": "டிராக்கர் எண்ணிக்கை",
"trackerSubscriptionNotUpdated": "இன்னும் புதுப்பிக்கப்படவில்லை",
"actions": "செயல்கள்"
"actions": "செயல்கள்",
"taskGroupDeleteImpact": "{taskCount} பதிவிறக்கங்களையும் {fileCount} கோப்புகளையும் பாதிக்கும்."
}
+5 -2
View File
@@ -372,7 +372,9 @@
"torrentDataEmpty": "Torrent dosya verileri boş",
"torrentPathUnavailable": "Hata dosya yoluna erişmek için kullanılamaz",
"selectAtLeastOneFile": "En az bir dosya seçin",
"resolvedFiles": "Çözülen Dosyalar",
"resolvedFiles": "Dosya listesi",
"waitingToResolve": "Çözümleme bekleniyor",
"resolvingResource": "Kaynak çözümleniyor",
"resolvedFileCount": "{count} dosyaları çözüldü",
"resolvedCategoryDirectory": "Şuraya kaydedilecek: {path}",
"resourceHasNoFiles": "Kaynak, uygun olmayan dosya listesine sahiptir",
@@ -477,5 +479,6 @@
"trackerSubscriptionUpdateStatus": "Güncelleme durumu",
"trackerSubscriptionTrackerCount": "Tracker sayısı",
"trackerSubscriptionNotUpdated": "Henüz güncellenmedi",
"actions": "İşlemler"
"actions": "İşlemler",
"taskGroupDeleteImpact": "{taskCount} indirmeyi ve {fileCount} dosyayı etkiler."
}
+5 -2
View File
@@ -372,7 +372,9 @@
"torrentDataEmpty": "Порожня інформація про файли, що обертаються",
"torrentPathUnavailable": "Неможливо отримати доступ до торрент-файлу доріжка",
"selectAtLeastOneFile": "Виберіть принаймні один файл",
"resolvedFiles": "Знезалізовані файли",
"resolvedFiles": "Список файлів",
"waitingToResolve": "Очікування аналізу",
"resolvingResource": "Аналіз ресурсу",
"resolvedFileCount": "{count} файли вирішені",
"resolvedCategoryDirectory": "Буде збережено в {path}",
"resourceHasNoFiles": "Ресурс не має вибраного списку файлів",
@@ -477,5 +479,6 @@
"trackerSubscriptionUpdateStatus": "Стан оновлення",
"trackerSubscriptionTrackerCount": "Кількість трекерів",
"trackerSubscriptionNotUpdated": "Ще не оновлено",
"actions": "Дії"
"actions": "Дії",
"taskGroupDeleteImpact": "Вплине на {taskCount} завантажень і {fileCount} файлів."
}
+5 -2
View File
@@ -372,7 +372,9 @@
"torrentDataEmpty": "Dữ liệu về tập tin đã rỗng",
"torrentPathUnavailable": "Không thể truy cập đường dẫn tập tin lũ lụt",
"selectAtLeastOneFile": "Chọn ít nhất một tập tin",
"resolvedFiles": "Các tập tin đã giải quyết",
"resolvedFiles": "Danh sách tệp",
"waitingToResolve": "Đang chờ phân tích",
"resolvingResource": "Đang phân tích tài nguyên",
"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",
@@ -477,5 +479,6 @@
"trackerSubscriptionUpdateStatus": "Trạng thái cập nhật",
"trackerSubscriptionTrackerCount": "Số tracker",
"trackerSubscriptionNotUpdated": "Chưa cập nhật",
"actions": "Thao tác"
"actions": "Thao tác",
"taskGroupDeleteImpact": "Ảnh hưởng đến {taskCount} tác vụ tải và {fileCount} tệp."
}
+5 -2
View File
@@ -372,7 +372,9 @@
"torrentDataEmpty": "种子文件内容为空",
"torrentPathUnavailable": "无法访问种子文件路径",
"selectAtLeastOneFile": "请至少选择一个文件",
"resolvedFiles": "已解析文件",
"resolvedFiles": "文件列表",
"waitingToResolve": "等待解析",
"resolvingResource": "正在解析资源",
"resolvedFileCount": "已解析 {count} 个文件",
"resolvedCategoryDirectory": "将保存到 {path}",
"resourceHasNoFiles": "该资源没有可选择的文件列表",
@@ -477,5 +479,6 @@
"trackerSubscriptionUpdateStatus": "更新状态",
"trackerSubscriptionTrackerCount": "Tracker 数量",
"trackerSubscriptionNotUpdated": "未更新",
"actions": "操作"
"actions": "操作",
"taskGroupDeleteImpact": "将影响 {taskCount} 个下载任务和 {fileCount} 个文件。"
}
+5 -2
View File
@@ -372,7 +372,9 @@
"torrentDataEmpty": "種子檔案內容為空",
"torrentPathUnavailable": "無法訪問種子檔案路徑",
"selectAtLeastOneFile": "請至少選擇一個檔案",
"resolvedFiles": "已解析檔案",
"resolvedFiles": "檔案清單",
"waitingToResolve": "等待解析",
"resolvingResource": "正在解析資源",
"resolvedFileCount": "已解析 {count} 個檔案",
"resolvedCategoryDirectory": "將儲存到 {path}",
"resourceHasNoFiles": "該資源沒有可選擇的檔案列表",
@@ -477,5 +479,6 @@
"trackerSubscriptionUpdateStatus": "更新狀態",
"trackerSubscriptionTrackerCount": "Tracker 數量",
"trackerSubscriptionNotUpdated": "未更新",
"actions": "操作"
"actions": "操作",
"taskGroupDeleteImpact": "將影響 {taskCount} 個下載任務和 {fileCount} 個檔案。"
}
+23 -4
View File
@@ -1,18 +1,37 @@
import 'package:flutter/material.dart' show Tooltip;
import 'package:flutter/material.dart' show Tooltip, TooltipState;
import 'package:flutter/widgets.dart';
/// The single product tooltip primitive.
///
/// Flutter's tooltip is used intentionally because the shadcn tooltip does not
/// currently inherit Gopeed's mixed Shadcn/Material theme consistently.
class AppTooltip extends StatelessWidget {
const AppTooltip({super.key, required this.message, required this.child});
class AppTooltip extends StatefulWidget {
const AppTooltip({super.key, required this.message, required this.child, this.focusable = false});
final String message;
final Widget child;
final bool focusable;
@override
State<AppTooltip> createState() => _AppTooltipState();
}
class _AppTooltipState extends State<AppTooltip> {
final _tooltipKey = GlobalKey<TooltipState>();
@override
Widget build(BuildContext context) {
return Tooltip(message: message, child: child);
final tooltip = Tooltip(key: _tooltipKey, message: widget.message, child: widget.child);
if (!widget.focusable) return tooltip;
return Focus(
onFocusChange: (focused) {
if (focused) {
_tooltipKey.currentState?.ensureTooltipVisible();
} else {
Tooltip.dismissAllToolTips();
}
},
child: tooltip,
);
}
}
@@ -16,6 +16,8 @@ class FileTreeItem<T> {
required this.name,
required this.size,
required this.data,
this.directoryOnly = false,
this.namespace = '',
});
final String key;
@@ -23,6 +25,10 @@ class FileTreeItem<T> {
final String name;
final int size;
final T data;
final bool directoryOnly;
/// Keeps directories from independent resources distinct without adding a visible root.
final String namespace;
}
class FileTreeNode<T> {
@@ -61,6 +67,7 @@ class FileTreeView<T> extends StatefulWidget {
this.headerLeading,
this.leadingBuilder,
this.iconBuilder,
this.iconWidgetBuilder,
this.trailingBuilder,
this.onNodePressed,
this.keyPrefix = 'file-tree',
@@ -71,12 +78,15 @@ class FileTreeView<T> extends StatefulWidget {
this.contentTextStyle,
this.iconSize = 18,
this.initialExpandedDepth,
this.directoryLabels = const {},
});
final List<FileTreeItem<T>> items;
final Map<String, String> directoryLabels;
final Widget? headerLeading;
final FileTreeNodeWidgetBuilder<T>? leadingBuilder;
final FileTreeNodeIconBuilder<T>? iconBuilder;
final FileTreeNodeWidgetBuilder<T>? iconWidgetBuilder;
final FileTreeNodeWidgetBuilder<T>? trailingBuilder;
final ValueChanged<FileTreeNode<T>>? onNodePressed;
final String keyPrefix;
@@ -115,7 +125,11 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProv
@override
void initState() {
super.initState();
_nodes = _buildTree(widget.items, expandedDepth: widget.initialExpandedDepth);
_nodes = _buildTree(
widget.items,
expandedDepth: widget.initialExpandedDepth,
directoryLabels: widget.directoryLabels,
);
_allFoldersExpanded = _foldersAreExpanded(_nodes);
_transitionController = AnimationController(vsync: this, duration: _treeTransitionDuration, value: 1)
..addStatusListener(_onTransitionStatus);
@@ -127,8 +141,12 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProv
super.didUpdateWidget(oldWidget);
if (identical(widget.items, oldWidget.items)) return;
final expandedKeys = _expandedFolderKeys(_nodes);
var nodes = _restoreExpandedFolders(_buildTree(widget.items), expandedKeys);
// During a collapse animation, the pending tree holds the user's target state.
final expandedKeys = _expandedFolderKeys(_pendingCollapsedNodes ?? _nodes);
var nodes = _restoreExpandedFolders(
_buildTree(widget.items, directoryLabels: widget.directoryLabels),
expandedKeys,
);
nodes = _sortNodes(nodes, _sortColumn, _sortDirection);
_nodes = nodes;
_allFoldersExpanded = _foldersAreExpanded(nodes);
@@ -171,7 +189,7 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProv
label,
() => _measureLabelWidth(label, style: labelStyle, textDirection: textDirection, textScaler: textScaler),
);
final nameContentWidth = _widestLabelWidth(widget.items, measureLabel);
final nameContentWidth = _widestLabelWidth(_nodes, measureLabel);
final fixedNameChrome =
_treeNodeExpandChrome + widget.iconSize + 4 + (widget.leadingBuilder == null ? 0 : _treeLeadingChrome);
@@ -179,7 +197,7 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProv
builder: (context, constraints) {
final treeWidth = constraints.hasBoundedWidth ? constraints.maxWidth : _treeFallbackWidth;
final horizontalOverflow = _horizontalOverflow(
widget.items,
_nodes,
baseNameViewport:
treeWidth - _treeHorizontalPadding - trailingColumnWidth - _treeTrailingGap - fixedNameChrome,
depthIndent: treeItemInset * 3,
@@ -283,14 +301,15 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProv
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (leading != null) ...[leading, const SizedBox(width: 8)],
Icon(
widget.iconBuilder?.call(data) ??
(data.isFolder
? (visuallyExpanded ? BootstrapIcons.folder2Open : BootstrapIcons.folder2)
: _fileIcon(data.label)),
size: widget.iconSize,
color: palette.textSecondary,
),
widget.iconWidgetBuilder?.call(context, data) ??
Icon(
widget.iconBuilder?.call(data) ??
(data.isFolder
? (visuallyExpanded ? BootstrapIcons.folder2Open : BootstrapIcons.folder2)
: _fileIcon(data.label)),
size: widget.iconSize,
color: palette.textSecondary,
),
const SizedBox(width: 4),
Expanded(
child: GestureDetector(
@@ -873,7 +892,11 @@ class _BuildNode<T> {
}
}
List<TreeNode<FileTreeNode<T>>> _buildTree<T>(List<FileTreeItem<T>> items, {int? expandedDepth}) {
List<TreeNode<FileTreeNode<T>>> _buildTree<T>(
List<FileTreeItem<T>> items, {
int? expandedDepth,
Map<String, String> directoryLabels = const {},
}) {
final root = <_BuildNode<T>>[];
final folders = <String, _BuildNode<T>>{};
var originalIndex = 0;
@@ -885,8 +908,13 @@ List<TreeNode<FileTreeNode<T>>> _buildTree<T>(List<FileTreeItem<T>> items, {int?
for (final part in parts) {
currentPath = currentPath.isEmpty ? part : '$currentPath/$part';
final folder = folders.putIfAbsent(currentPath, () {
final node = _BuildNode<T>(key: 'folder:$currentPath', label: part, originalIndex: originalIndex++);
final directoryKey = item.namespace.isEmpty ? currentPath : '${item.namespace}:$currentPath';
final folder = folders.putIfAbsent(directoryKey, () {
final node = _BuildNode<T>(
key: 'folder:$directoryKey',
label: directoryLabels[directoryKey] ?? part,
originalIndex: originalIndex++,
);
if (parent == null) {
root.add(node);
} else {
@@ -897,6 +925,8 @@ List<TreeNode<FileTreeNode<T>>> _buildTree<T>(List<FileTreeItem<T>> items, {int?
parent = folder;
}
if (item.directoryOnly) continue;
final fileNode = _BuildNode<T>(
key: 'file:${item.key}',
label: item.name,
@@ -945,10 +975,10 @@ List<TreeNode<FileTreeNode<T>>> _sortNodes<T>(
sorted.sort((a, b) {
final left = a.data;
final right = b.data;
if (left.isFolder != right.isFolder) return left.isFolder ? -1 : 1;
if (column == null || direction == _FileTreeSortDirection.none) {
return left.originalIndex.compareTo(right.originalIndex);
}
if (left.isFolder != right.isFolder) return left.isFolder ? -1 : 1;
final comparison = switch (column) {
_FileTreeSortColumn.name => left.label.toLowerCase().compareTo(right.label.toLowerCase()),
_FileTreeSortColumn.size => left.size.compareTo(right.size),
@@ -994,7 +1024,7 @@ Set<String> _expandedFolderKeys<T>(Iterable<TreeNode<FileTreeNode<T>>> nodes) {
final keys = <String>{};
void collect(TreeNode<FileTreeNode<T>> node) {
final item = node as TreeItemNode<FileTreeNode<T>>;
if (item.children.isNotEmpty && item.expanded) keys.add(item.data.key);
if (item.data.isFolder && item.expanded) keys.add(item.data.key);
for (final child in item.children) {
collect(child);
}
@@ -1016,7 +1046,7 @@ List<TreeNode<FileTreeNode<T>>> _restoreExpandedFolders<T>(
final children = _restoreExpandedFolders(item.children, expandedKeys);
return item
.updateChildren(children)
.updateState(expanded: item.children.isNotEmpty && expandedKeys.contains(item.data.key));
.updateState(expanded: item.data.isFolder && expandedKeys.contains(item.data.key));
})
.toList(growable: false);
}
@@ -1036,13 +1066,12 @@ String _extensionOf(String name) {
return name.substring(index + 1).toLowerCase();
}
double _widestLabelWidth<T>(List<FileTreeItem<T>> items, double Function(String label) measureLabel) {
double _widestLabelWidth<T>(List<TreeNode<FileTreeNode<T>>> nodes, double Function(String label) measureLabel) {
var widest = _treeMinimumNameContentWidth;
for (final item in items) {
widest = math.max(widest, measureLabel(item.name));
for (final segment in item.path.split(RegExp(r'[/\\]+'))) {
if (segment.isNotEmpty) widest = math.max(widest, measureLabel(segment));
}
for (final node in nodes) {
final item = node as TreeItemNode<FileTreeNode<T>>;
widest = math.max(widest, measureLabel(item.data.label));
widest = math.max(widest, _widestLabelWidth(item.children, measureLabel));
}
return widest;
}
@@ -1063,20 +1092,27 @@ double _measureLabelWidth(
}
double _horizontalOverflow<T>(
List<FileTreeItem<T>> items, {
List<TreeNode<FileTreeNode<T>>> nodes, {
required double baseNameViewport,
required double depthIndent,
required double Function(String label) measureLabel,
int depth = 0,
}) {
var overflow = 0.0;
for (final item in items) {
final segments = item.path.split(RegExp(r'[/\\]+')).where((segment) => segment.isNotEmpty).toList();
for (var depth = 0; depth < segments.length; depth++) {
final viewport = math.max(_treeMinimumNameViewport, baseNameViewport - (depth * depthIndent));
overflow = math.max(overflow, measureLabel(segments[depth]) - viewport);
}
final fileViewport = math.max(_treeMinimumNameViewport, baseNameViewport - (segments.length * depthIndent));
overflow = math.max(overflow, measureLabel(item.name) - fileViewport);
final viewport = math.max(_treeMinimumNameViewport, baseNameViewport - depth * depthIndent);
for (final node in nodes) {
final item = node as TreeItemNode<FileTreeNode<T>>;
overflow = math.max(overflow, measureLabel(item.data.label) - viewport);
overflow = math.max(
overflow,
_horizontalOverflow(
item.children,
baseNameViewport: baseNameViewport,
depthIndent: depthIndent,
measureLabel: measureLabel,
depth: depth + 1,
),
);
}
return math.max(0, overflow);
}
+36
View File
@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/api.dart' as api;
import 'package:gopeed/api/model/create_task.dart';
import 'package:gopeed/api/model/create_task_batch.dart';
import 'package:gopeed/api/model/options.dart';
import 'package:gopeed/api/model/patch_task.dart';
import 'package:gopeed/api/model/request.dart';
@@ -39,6 +40,32 @@ void main() {
expect((createBody['opts'] as Map<String, dynamic>)['selectFiles'], [1]);
});
test('batch sends rid and options and decodes the unified response in both modes', () async {
for (final grouped in [false, true]) {
final transport = _RecordingTransport();
api.setTransportForTesting(transport);
final result = await api.createTaskBatch(
CreateTaskBatch(
group: grouped,
groupName: grouped ? 'folder' : '',
reqs: [
CreateTaskBatchItem(
rid: 'resolved-id',
opts: Options(selectFiles: [1]),
),
],
),
);
expect(result.taskIds, ['created-id']);
expect(result.parentId, grouped ? 'parent' : null);
final body = transport.data as Map<String, dynamic>;
final member = (body['reqs'] as List).single as Map<String, dynamic>;
expect(member['rid'], 'resolved-id');
expect((member['opts'] as Map)['selectFiles'], [1]);
expect(body, isNot(contains('groupId')));
}
});
test('patch preserves options in its independent request model', () async {
final transport = _RecordingTransport();
api.setTransportForTesting(transport);
@@ -59,6 +86,15 @@ class _RecordingTransport implements GopeedTransport {
Map<String, dynamic>? queryParameters,
}) async {
this.data = jsonDecode(jsonEncode(data));
if (path == 'api/v1/tasks/batch') {
return {
'code': 0,
'data': {
'taskIds': ['created-id'],
if (this.data['group'] == true) 'parentId': 'parent',
},
};
}
if (path == 'api/v1/tasks') {
return {'code': 0, 'data': 'created-id'};
}
@@ -1,4 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/model/create_task_batch.dart';
import 'package:gopeed/api/model/options.dart';
import 'package:gopeed/api/model/patch_task.dart';
import 'package:gopeed/api/model/request.dart';
@@ -21,6 +22,47 @@ class _Payload {
}
void main() {
test('group creation crosses the window boundary without losing directory or child options', () async {
final codecs = createAppCapabilityCodecs();
CreateTaskBatch? submitted;
final registry = CapabilityRegistry(codecs)
..bind(GopeedMethods.createTaskBatch, (batch) {
submitted = batch;
return CreateTaskBatchResult(taskIds: ['child'], parentId: 'generated');
});
final batch = CreateTaskBatch(
group: true,
groupName: 'folder',
opts: Options(path: '/downloads'),
reqs: [
CreateTaskBatchItem(
rid: 'resolved-rid',
req: Request(url: 'https://example.com/file'),
opts: Options(path: '/downloads/folder/nested', name: 'file.txt'),
),
],
);
final result = codecs.decode<CreateTaskBatchResult>(
await registry.invoke(GopeedMethods.createTaskBatch.name, codecs.encode(batch)),
);
expect(result.taskIds, ['child']);
expect(result.parentId, 'generated');
expect(submitted?.group, isTrue);
expect(submitted?.reqs?.single.rid, 'resolved-rid');
expect(submitted?.toJson().containsKey('groupId'), isFalse);
expect(submitted?.opts?.path, '/downloads');
expect(submitted?.reqs?.single.opts?.path, '/downloads/folder/nested');
expect(CreateTaskBatch.fromJson({'reqs': []}).group, isFalse);
expect(
CreateTaskBatchResult.fromJson({
'taskIds': ['ordinary'],
}).toJson(),
{
'taskIds': ['ordinary'],
},
);
});
test('serialized resolve and patch capabilities keep separate request contracts', () async {
final codecs = createAppCapabilityCodecs();
String? resolvedUrl;
@@ -28,7 +70,10 @@ void main() {
final registry = CapabilityRegistry(codecs)
..bind(GopeedMethods.resolve, (request) {
resolvedUrl = request.req?.url;
return ResolveResult(id: 'resolved-id', res: Resource(files: []));
return ResolveResult(
id: 'resolved-id',
res: Resource(files: []),
);
})
..bind(GopeedMethods.patchTask, (request) {
patch = request;
@@ -0,0 +1,213 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/api.dart' as api;
import 'package:gopeed/api/model/downloader_config.dart';
import 'package:gopeed/core/capabilities/app_capabilities.dart';
import 'package:gopeed/core/capabilities/storage_capability.dart';
import 'package:gopeed/core/network/gopeed/gopeed_transport.dart';
import 'package:gopeed/features/tasks/presentation/pages/create_task_window_page.dart';
import 'package:gopeed/shared/theme/app_component_themes.dart';
import 'package:gopeed/shared/theme/app_theme.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
void main() {
final host = LocalAppCapabilities.instance;
late _ConfigTransport transport;
setUp(() {
transport = _ConfigTransport();
api.setTransportForTesting(transport);
});
test('local and child saves serialize the full history read-modify-write', () async {
final first = host.capabilities.storage.saveCreateHistory(['A']);
await transport.firstPutStarted.future;
final second = host.registry.invoke(StorageMethods.saveCreateHistory.name, ['B']);
await Future<void>.delayed(Duration.zero);
final readsWhileSaving = transport.reads;
transport.releaseFirstPut.complete();
await Future.wait<Object?>([first, second]);
expect(transport.config.extra.createHistory, ['B', 'A']);
expect(readsWhileSaving, 1, reason: 'the next GET must wait for the previous PUT');
});
test('history and config reads wait for previously requested saves', () async {
final saving = host.capabilities.storage.saveCreateHistory(['A']);
await transport.firstPutStarted.future;
final history = host.capabilities.storage.getCreateHistory();
final config = host.capabilities.gopeed.getConfig();
await Future<void>.delayed(Duration.zero);
final readsWhileSaving = transport.reads;
transport.releaseFirstPut.complete();
await saving;
expect(await history, ['A']);
expect((await config).extra.createHistory, ['A']);
expect(readsWhileSaving, 1);
});
for (final historyFirst in [true, false]) {
test('overlapping full config and history saves preserve both changes (history first: $historyFirst)', () async {
transport.config.extra.createHistory = ['existing'];
final snapshot = await host.capabilities.gopeed.getConfig();
snapshot.downloadDir = '/chosen';
snapshot.maxRunning = 7;
final first = historyFirst
? host.capabilities.storage.saveCreateHistory(['A'])
: host.capabilities.gopeed.putConfig(snapshot);
await transport.firstPutStarted.future;
final second = historyFirst
? host.capabilities.gopeed.putConfig(snapshot)
: host.capabilities.storage.saveCreateHistory(['A']);
await Future<void>.delayed(Duration.zero);
transport.releaseFirstPut.complete();
await Future.wait([first, second]);
expect(transport.config.extra.createHistory, ['A', 'existing']);
expect(transport.config.downloadDir, '/chosen');
expect(transport.config.maxRunning, 7);
});
}
test('an old full config cannot restore removed or cleared history', () async {
transport.blockFirstPut = false;
transport.config.extra.createHistory = ['A', 'B'];
final snapshot = await host.capabilities.gopeed.getConfig();
await host.capabilities.storage.removeCreateHistory('A');
await host.capabilities.gopeed.putConfig(snapshot);
expect(transport.config.extra.createHistory, ['B']);
await host.capabilities.storage.clearCreateHistory();
await host.capabilities.gopeed.putConfig(snapshot);
expect(transport.config.extra.createHistory, isEmpty);
expect(snapshot.extra.createHistory, ['A', 'B'], reason: 'saving must not mutate the caller snapshot');
});
test('history save, remove, clear and save follow request order', () async {
transport.config.extra.createHistory = ['existing'];
final first = host.capabilities.storage.saveCreateHistory(['A']);
await transport.firstPutStarted.future;
final remove = host.capabilities.storage.removeCreateHistory('existing');
final clear = host.capabilities.storage.clearCreateHistory();
final second = host.capabilities.storage.saveCreateHistory(['B']);
await Future<void>.delayed(Duration.zero);
transport.releaseFirstPut.complete();
await Future.wait([first, remove, clear, second]);
expect(transport.config.extra.createHistory, ['B']);
});
test('a failed save reports its error without blocking subsequent saves or reads', () async {
transport.failNextPut = true;
final first = host.capabilities.storage.saveCreateHistory(['A']);
final failed = expectLater(first, throwsA(isA<StateError>()));
await transport.firstPutStarted.future;
final second = host.capabilities.storage.saveCreateHistory(['B']);
final history = host.capabilities.storage.getCreateHistory();
transport.releaseFirstPut.complete();
await failed;
await second;
expect(await history, ['B']);
});
testWidgets('confirm A, cancel, confirm B saves both while previews open immediately', (tester) async {
tester.view.physicalSize = const Size(900, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
ProviderScope(
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: AppComponentThemes(child: const CreateTaskWindowPage()),
),
),
);
// The host queue is created outside the widget clock.
await tester.runAsync(() => Future<void>.delayed(Duration.zero));
await tester.pumpAndSettle();
final input = find.byKey(const ValueKey('create-task-url-input'));
await tester.enterText(input, 'https://example.com/A');
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
await tester.runAsync(() => Future<void>.delayed(Duration.zero));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(transport.firstPutStarted.isCompleted, isTrue);
expect(transport.releaseFirstPut.isCompleted, isFalse);
expect(find.byKey(const ValueKey('resolve-cancel-button')), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
await tester.pumpAndSettle();
await tester.enterText(input, 'https://example.com/B');
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
await tester.runAsync(() => Future<void>.delayed(Duration.zero));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.byKey(const ValueKey('resolve-cancel-button')), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(shad.Icons.history));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
transport.releaseFirstPut.complete();
await tester.runAsync(() => Future<void>.delayed(Duration.zero));
await tester.pumpAndSettle();
expect(transport.config.extra.createHistory, ['https://example.com/B', 'https://example.com/A']);
for (final url in ['https://example.com/A', 'https://example.com/B']) {
expect(find.byKey(ValueKey('create-history-item-$url')), findsOneWidget);
}
expect(tester.takeException(), isNull);
});
}
class _ConfigTransport implements GopeedTransport {
DownloaderConfig config = DownloaderConfig(downloadDir: '/downloads');
final firstPutStarted = Completer<void>();
final releaseFirstPut = Completer<void>();
bool blockFirstPut = true;
bool failNextPut = false;
int reads = 0;
int writes = 0;
@override
Future<dynamic> request(
String path, {
String method = 'GET',
dynamic data,
Map<String, dynamic>? queryParameters,
}) async {
if (path == 'api/v1/resolve') {
return {
'code': 0,
'data': {
'id': 'rid',
'res': {
'files': [
{'name': 'file.txt'},
],
},
},
};
}
if (path != 'api/v1/config') throw StateError('Unexpected request: $method $path');
if (method == 'GET') {
reads++;
return {'code': 0, 'data': jsonDecode(jsonEncode(config))};
}
final snapshot = DownloaderConfig.fromJson(jsonDecode(jsonEncode(data)) as Map<String, dynamic>);
final shouldFail = failNextPut;
failNextPut = false;
if (++writes == 1 && blockFirstPut) {
firstPutStarted.complete();
await releaseFirstPut.future;
}
if (shouldFail) {
throw StateError('config save failed');
}
config = snapshot;
return {'code': 0};
}
@override
String join(String path) => path;
}
@@ -0,0 +1,396 @@
import 'dart:io';
import 'package:flutter/gestures.dart' show kSecondaryMouseButton;
import 'package:flutter/material.dart' show Icons;
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:gopeed/api/model/task.dart' as api;
import 'package:gopeed/features/home/presentation/pages/home_page.dart';
import 'package:gopeed/features/tasks/application/tasks_controller.dart';
import 'package:gopeed/features/tasks/domain/task_record.dart';
import 'package:gopeed/features/tasks/presentation/pages/task_details_page.dart';
import 'package:gopeed/features/tasks/presentation/pages/task_files_page.dart';
import 'package:gopeed/features/tasks/presentation/widgets/task_card/task_card.dart';
import 'package:gopeed/features/tasks/presentation/widgets/task_drawer.dart';
import 'package:gopeed/app/application/app_runtime_controller.dart';
import 'package:gopeed/api/model/downloader_config.dart';
import 'package:gopeed/core/common/start_config.dart';
import 'package:gopeed/core/common/api_server_state.dart';
import 'package:gopeed/core/capabilities/app_capabilities.dart';
import 'package:gopeed/core/capabilities/capability_rpc.dart';
import 'package:gopeed/core/capabilities/gopeed_capability.dart';
import 'package:gopeed/features/tasks/presentation/widgets/task_file_manager.dart';
import 'package:gopeed/shared/theme/app_theme.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
api.Task member(String id, {String name = 'member.txt', bool bt = false, String status = 'running'}) =>
api.Task.fromJson({
'id': id,
'name': name,
'parentId': 'group',
'group': {'id': 'group', 'name': 'bundle', 'path': '/downloads/bundle', 'createdAt': '2026-01-01T00:00:00Z'},
'protocol': bt ? 'bt' : 'http',
'status': status,
'uploading': false,
'createdAt': '2026-01-01T00:00:00Z',
'updatedAt': '2026-01-01T00:00:00Z',
'meta': {
'req': {'url': bt ? 'magnet:?xt=urn:btih:test' : 'https://example.com/$id'},
'opts': {'path': '/downloads/bundle', 'name': name, 'size': 100},
'res': {
'name': bt ? name : '',
'size': 100,
'files': [
{'name': bt ? 'bt-first.txt' : name, 'size': 50},
if (bt) {'name': 'bt-second.txt', 'size': 50},
],
},
},
'progress': {'used': 0, 'speed': 0, 'downloaded': 25, 'uploaded': 0, 'uploadSpeed': 0},
});
class GroupTasks extends TasksController {
String name = 'member.txt';
String childStatus = 'running';
final paused = <String>[];
final resumed = <String>[];
final deleted = <String>[];
TasksState snapshot() => TasksState(
tasks: taskRecordsFromApi([
member('child', name: name, status: childStatus),
member('torrent', name: 'torrent bundle', bt: true, status: 'pause'),
]),
);
@override
Future<TasksState> build() async => snapshot();
void rename() {
name = 'renamed.txt';
state = AsyncData(snapshot());
}
void completeMember() {
childStatus = 'done';
state = AsyncData(snapshot());
}
@override
Future<void> pause(String id) async => paused.add(id);
@override
Future<void> pauseAll(List<String>? ids) async => paused.addAll(ids!);
@override
Future<void> resume(String id) async => resumed.add(id);
@override
Future<void> resumeAll(List<String>? ids) async => resumed.addAll(ids!);
@override
Future<void> deleteSelected(List<String>? ids, {bool force = false}) async {
deleted.addAll(ids!);
state = AsyncData(
TasksState(
tasks: taskRecordsFromApi([
if (!deleted.contains('child') && !deleted.contains('group')) member('child', name: name),
if (!deleted.contains('torrent') && !deleted.contains('group'))
member('torrent', name: 'torrent bundle', bt: true, status: 'pause'),
]),
),
);
}
}
class GroupRuntime extends AppRuntimeController {
@override
Future<AppRuntimeState> build() async => AppRuntimeState(
startConfig: StartConfig(),
apiServerState: const ApiServerState(
enabled: false,
mcpEnabled: false,
running: false,
network: '',
address: '',
runningPort: 0,
pendingApply: false,
lastError: '',
),
downloaderConfig: DownloaderConfig(),
);
@override
Future<void> reloadConfig() async {}
}
Future<void> settle(WidgetTester tester) async {
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
}
Finder card(String id) => find.byWidgetPredicate((widget) => widget is TaskCard && widget.task.id == id);
Future<({GroupTasks controller, GoRouter router})> showHome(WidgetTester tester, double width) async {
tester.view.physicalSize = Size(width, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final router = GoRouter(
routes: [
GoRoute(path: '/', builder: (_, _) => const HomePage()),
GoRoute(
path: '/tasks/:id',
builder: (_, state) =>
TaskDetailsPage(taskId: state.pathParameters['id']!, initialTask: state.extra as TaskRecord?),
),
GoRoute(
path: '/tasks/:id/files',
builder: (_, state) =>
TaskFilesPage(taskId: state.pathParameters['id']!, initialTask: state.extra as TaskRecord?),
),
],
);
addTearDown(router.dispose);
late GroupTasks controller;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.putConfig, (_) => const RpcUnit());
await tester.pumpWidget(
ProviderScope(
overrides: [
tasksControllerProvider.overrideWith(() => controller = GroupTasks()),
appRuntimeControllerProvider.overrideWith(GroupRuntime.new),
appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry))),
],
child: shad.ShadcnApp.router(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
routerConfig: router,
),
),
);
await settle(tester);
return (controller: controller, router: router);
}
void main() {
for (final width in [390.0, 700.0, 1100.0]) {
testWidgets('group expands actual member tasks and keeps a multi-file BT as one card at $width', (tester) async {
final app = await showHome(tester, width);
expect(card('group'), findsOneWidget);
expect(card('child'), findsNothing);
await tester.tap(find.text('bundle'));
await settle(tester);
expect(card('child'), findsOneWidget);
expect(card('torrent'), findsOneWidget);
expect(find.text('bt-first.txt'), findsNothing);
expect(find.text('bt-second.txt'), findsNothing);
expect(find.byType(TaskFileManagerView), findsNothing);
expect(find.byType(TaskDetailsView), findsNothing);
app.controller.rename();
await settle(tester);
expect(tester.widget<TaskCard>(card('child')).task.name, 'renamed.txt');
await tester.tap(find.text('bundle'));
await settle(tester);
expect(card('child'), findsNothing);
expect(card('torrent'), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('member click opens its own details and polling refreshes the selected member at $width', (
tester,
) async {
final app = await showHome(tester, width);
await tester.tap(find.text('bundle'));
await settle(tester);
await tester.tap(find.text('member.txt'));
await settle(tester);
expect(tester.widget<TaskDetailsView>(find.byType(TaskDetailsView).last).task.id, 'child');
app.controller.rename();
await settle(tester);
expect(tester.widget<TaskDetailsView>(find.byType(TaskDetailsView).last).task.name, 'renamed.txt');
expect(tester.takeException(), isNull);
});
}
testWidgets('member context menu and card actions target member IDs while group actions keep the group ID', (
tester,
) async {
final app = await showHome(tester, 1100);
await tester.tap(find.text('bundle'));
await settle(tester);
await tester.tapAt(tester.getCenter(card('child')), buttons: kSecondaryMouseButton);
await settle(tester);
await tester.tap(find.byKey(const ValueKey('task-context-pause')));
await settle(tester);
expect(app.controller.paused, ['child']);
tester.widget<TaskCard>(card('torrent')).onResume!();
await settle(tester);
expect(app.controller.resumed, ['torrent']);
tester.widget<TaskCard>(card('group')).onPause!();
await settle(tester);
expect(app.controller.paused, ['child', 'group']);
expect(tester.takeException(), isNull);
});
for (final width in [390.0, 1100.0]) {
testWidgets('group resume button targets the group at $width', (tester) async {
final app = await showHome(tester, width);
await tester.tap(find.descendant(of: card('group'), matching: find.byIcon(Icons.play_arrow)));
await settle(tester);
expect(app.controller.resumed, ['group']);
expect(card('child'), findsNothing);
expect(tester.takeException(), isNull);
});
}
for (final open in [true, false]) {
testWidgets('member card and menu ${open ? 'open' : 'reveal'} use the member path', (tester) async {
final app = await showHome(tester, 1100);
app.controller.completeMember();
await settle(tester);
await tester.tap(find.text('bundle'));
await settle(tester);
final checkedPaths = <String>[];
final child = tester.widget<TaskCard>(card('child'));
await IOOverrides.runZoned(
() async {
(open ? child.onOpen : child.onReveal)!();
await settle(tester);
expect(checkedPaths.first, '/downloads/bundle/member.txt');
checkedPaths.clear();
await tester.tapAt(tester.getCenter(card('child')), buttons: kSecondaryMouseButton);
await settle(tester);
await tester.tap(find.byKey(ValueKey(open ? 'task-context-open-file' : 'task-context-open-directory')));
await settle(tester);
expect(checkedPaths.first, '/downloads/bundle/member.txt');
},
fseGetType: (path, followLinks) async {
checkedPaths.add(path);
return FileSystemEntityType.notFound;
},
);
// Allow the failure-path toast to close without launching native tools.
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(seconds: 6));
expect(tester.takeException(), isNull);
});
}
testWidgets('member menu details uses the member instead of the group', (tester) async {
await showHome(tester, 1100);
await tester.tap(find.text('bundle'));
await settle(tester);
await tester.tapAt(tester.getCenter(card('torrent')), buttons: kSecondaryMouseButton);
await settle(tester);
await tester.tap(find.byKey(const ValueKey('task-context-details')));
await settle(tester);
expect(tester.widget<TaskDetailsView>(find.byType(TaskDetailsView).last).task.id, 'torrent');
expect(tester.widget<TaskDetailsView>(find.byType(TaskDetailsView).last).task.files, hasLength(2));
expect(tester.takeException(), isNull);
});
testWidgets('selecting a member through its context menu enables batch actions for that member', (tester) async {
final app = await showHome(tester, 1100);
await tester.tap(find.text('bundle'));
await settle(tester);
await tester.tapAt(tester.getCenter(card('child')), buttons: kSecondaryMouseButton);
await settle(tester);
await tester.tap(find.byKey(const ValueKey('task-context-select')));
await settle(tester);
expect(tester.widget<TaskCard>(card('child')).selectedInBatch, isTrue);
final pause = find.byIcon(Icons.pause_outlined);
await tester.tap(pause);
await settle(tester);
expect(app.controller.paused, ['child']);
expect(tester.takeException(), isNull);
});
testWidgets('deleting a member leaves its sibling group intact', (tester) async {
final app = await showHome(tester, 1100);
await tester.tap(find.text('bundle'));
await settle(tester);
await tester.tapAt(tester.getCenter(card('child')), buttons: kSecondaryMouseButton);
await settle(tester);
await tester.tap(find.byKey(const ValueKey('task-context-delete')));
await settle(tester);
expect(find.byType(shad.DestructiveButton), findsOneWidget);
await tester.tap(find.byType(shad.DestructiveButton));
await settle(tester);
expect(app.controller.deleted, ['child']);
expect(card('group'), findsOneWidget);
expect(card('child'), findsNothing);
expect(card('torrent'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('deleting a selected group and its members counts and submits the group once', (tester) async {
final app = await showHome(tester, 1100);
await tester.tap(find.text('bundle'));
await settle(tester);
await tester.tapAt(tester.getCenter(card('group')), buttons: kSecondaryMouseButton);
await settle(tester);
await tester.tap(find.byKey(const ValueKey('task-context-select-all')));
await settle(tester);
await tester.tapAt(tester.getCenter(card('child')), buttons: kSecondaryMouseButton);
await settle(tester);
await tester.tap(find.byKey(const ValueKey('task-context-delete')));
await settle(tester);
expect(find.text('Delete task?'), findsOneWidget);
await tester.tap(find.byType(shad.DestructiveButton));
await settle(tester);
expect(app.controller.deleted, ['group']);
expect(card('group'), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('member context file browser polls its member record', (tester) async {
final app = await showHome(tester, 1100);
await tester.tap(find.text('bundle'));
await settle(tester);
await tester.tapAt(tester.getCenter(card('child')), buttons: kSecondaryMouseButton);
await settle(tester);
await tester.tap(find.byKey(const ValueKey('task-context-browse-files')));
await settle(tester);
expect(tester.widget<TaskFileManagerView>(find.byType(TaskFileManagerView)).task.name, 'member.txt');
app.controller.rename();
await settle(tester);
expect(tester.widget<TaskFileManagerView>(find.byType(TaskFileManagerView)).task.name, 'renamed.txt');
expect(tester.takeException(), isNull);
});
testWidgets('group clicks in batch mode select without collapsing members', (tester) async {
await showHome(tester, 1100);
await tester.tap(find.text('bundle'));
await settle(tester);
await tester.tapAt(tester.getCenter(card('child')), buttons: kSecondaryMouseButton);
await settle(tester);
await tester.tap(find.byKey(const ValueKey('task-context-select')));
await settle(tester);
await tester.tap(find.text('bundle'));
await settle(tester);
expect(tester.widget<TaskCard>(card('group')).selectedInBatch, isTrue);
expect(tester.widget<TaskCard>(card('group')).expanded, isTrue);
expect(card('child'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('member detail route resolves the member without an initial snapshot', (tester) async {
final app = await showHome(tester, 390);
app.router.go('/tasks/child');
await settle(tester);
expect(tester.widget<TaskDetailsView>(find.byType(TaskDetailsView).last).task.id, 'child');
app.controller.rename();
await settle(tester);
expect(tester.widget<TaskDetailsView>(find.byType(TaskDetailsView).last).task.name, 'renamed.txt');
expect(tester.takeException(), isNull);
});
testWidgets('member file page resolves current data from its group without a route snapshot', (tester) async {
final app = await showHome(tester, 390);
app.router.go('/tasks/child/files');
await settle(tester);
expect(tester.widget<TaskFileManagerView>(find.byType(TaskFileManagerView)).task.name, 'member.txt');
app.controller.rename();
await settle(tester);
expect(tester.widget<TaskFileManagerView>(find.byType(TaskFileManagerView)).task.name, 'renamed.txt');
expect(tester.takeException(), isNull);
});
}
@@ -0,0 +1,272 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/model/create_task_batch.dart';
import 'package:gopeed/api/model/options.dart';
import 'package:gopeed/api/model/request.dart';
import 'package:gopeed/api/model/resource.dart';
import 'package:gopeed/api/model/resolve_result.dart';
import 'package:gopeed/core/capabilities/app_capabilities.dart';
import 'package:gopeed/core/capabilities/capability_rpc.dart';
import 'package:gopeed/core/capabilities/gopeed_capability.dart';
import 'package:gopeed/features/tasks/application/pending_download.dart';
PendingDownload source(String id, {String name = 'bundle', String rid = '', int count = 2, String? url}) =>
PendingDownload(
id: id,
request: Request(url: url ?? 'https://example.com/$id'),
options: Options(path: '/downloads'),
)
..result = ResolveResult(
id: rid,
res: Resource(
name: name,
files: List.generate(
count,
(i) => FileInfo(
name: '$i.txt',
size: 100 + i,
req: Request(url: 'https://example.com/$id/$i'),
),
),
),
)
..selected.addAll(List.generate(count, (i) => i));
GopeedService service(CapabilityRegistry registry) => GopeedService(LocalCapabilityInvoker(registry));
void main() {
test('one named extension group and all independent sources produce exactly two batches', () {
final group = source('a')..selected.remove(0);
final unnamed = source('b', name: '');
final torrent = source('c', rid: 'bt-rid')..selected.remove(0);
final http = source('d', rid: 'http-rid', count: 1, name: '');
final batches = planDownloads([group, unnamed, torrent, http]);
expect(batches, hasLength(2));
final grouped = batches.first.request;
expect(grouped.group, isTrue);
expect(grouped.groupName, 'bundle');
expect(grouped.opts!.path, '/downloads');
expect(grouped.reqs!.single.req!.url, 'https://example.com/a/1');
expect(grouped.toJson(), isNot(contains('groupId')));
final normal = batches.last.request;
expect(normal.group, isFalse);
expect(normal.reqs, hasLength(4));
expect(normal.reqs!.take(2).every((item) => item.req != null && item.rid == null), isTrue);
expect(normal.reqs![2].rid, 'bt-rid');
expect(normal.reqs![2].opts!.selectFiles, [1]);
expect(normal.reqs![3].rid, 'http-rid');
expect(normal.reqs![3].req, isNull);
});
test('successful batches are complete even when only some task IDs are returned', () async {
var calls = 0;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.createTaskBatch, (_) {
calls++;
return CreateTaskBatchResult(taskIds: ['only-one']);
});
final sources = [source('a', name: ''), source('b', rid: 'rid')];
await planDownloads(sources).single.submit(service(registry));
expect(sources.every((s) => s.created && s.createError == null), isTrue);
expect(planDownloads(sources), isEmpty);
expect(calls, 1);
});
test('retry only submits failed batches and never appends to the successful group', () async {
final submitted = <CreateTaskBatch>[];
var failNormal = true;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.createTaskBatch, (batch) {
submitted.add(batch);
if (!batch.group && failNormal) throw StateError('all failed');
return CreateTaskBatchResult(taskIds: ['created'], parentId: batch.group ? 'parent' : null);
});
final sources = [source('group'), source('normal', name: '')];
for (final batch in planDownloads(sources)) {
await batch.submit(service(registry));
}
expect(sources.first.created, isTrue);
expect(sources.last.created, isFalse);
expect(sources.last.createError, contains('all failed'));
failNormal = false;
final retry = planDownloads(sources);
expect(retry, hasLength(1));
await retry.single.submit(service(registry));
expect(submitted.where((batch) => batch.group), hasLength(1));
expect(planDownloads(sources), isEmpty);
});
test('single extension keeps nested resource directory and empty base remains empty', () {
final single = source('single', count: 1, name: 'album');
single.result!.res.files.single.path = 'disc1';
expect(single.isGroup, isFalse);
expect(planDownloads([single]).single.request.reqs!.single.opts!.path, '/downloads/album/disc1');
final plain = source('plain', count: 1, name: '')..options.path = '';
expect(plain.creationItems().single.opts!.path, '');
});
for (final parent in ['', 'downloads', './downloads', '/downloads']) {
test('group members use paths relative to their group for parent "$parent"', () {
final pending = source('group')..options.path = parent;
pending.files[1].path = 'nested';
final batch = planDownloads([pending]).single.request;
expect(batch.group, isTrue);
expect(batch.opts!.path, parent);
expect(batch.reqs!.map((item) => item.opts!.path), ['', 'nested']);
expect(pending.options.path, parent);
});
}
test('unnamed extension members retain their ordinary batch destination', () {
final pending = source('independent', name: '')..options.path = 'downloads';
pending.files[1].path = 'nested';
final batch = planDownloads([pending]).single.request;
expect(batch.group, isFalse);
expect(batch.reqs!.map((item) => item.opts!.path), ['downloads', 'downloads/nested']);
});
test('members preserve directory intent, HTTP sizes and per-file checksum rules', () {
final pending = source('group');
pending.options.extra = OptsExtraHttp(
checksum: ChecksumOption(algorithm: 'md5', expected: 'abc'),
).toJson();
final members = pending.creationItems();
expect(members.first.opts!.size, 100);
expect(members.first.opts!.asDefaultPath, isFalse);
expect(members.first.opts!.skipAutoCategorize, isTrue);
expect(members.first.opts!.extra as Map, isNot(contains('checksum')));
pending.selected.remove(0);
expect(pending.creationItems().single.opts!.extra as Map, contains('checksum'));
expect(pending.options.extra as Map, contains('checksum'));
pending.result!.res.files[1].req = Request(url: 'magnet:?xt=urn:btih:test');
expect(pending.creationItems().single.opts!.size, 0);
});
test('FTP extension members use FTP options and keep their request TLS mode', () {
final pending = source('group');
pending.options.extra = OptsExtraHttp(
connections: 64,
autoExtract: true,
archivePassword: 'secret',
checksum: ChecksumOption(algorithm: 'md5', expected: 'abc'),
).toJson();
pending.files[0].req = Request(
url: 'ftp://files.example/archive.zip',
extra: ReqExtraFtp(tls: 'explicit').toJson(),
);
pending.selected.remove(1);
final item = pending.creationItems().single;
expect(item.req!.extra, {'tls': 'explicit'});
expect(item.opts!.size, 0);
final extra = item.opts!.extra as Map;
expect(extra['connections'], 32);
expect(extra['autoExtract'], isTrue);
expect(extra['archivePassword'], 'secret');
expect(extra.containsKey('checksum'), isFalse);
expect((pending.options.extra as Map)['connections'], 64);
});
test('member option changes do not affect other members or source options', () {
final pending = source('group');
pending.options
..name = 'custom'
..size = 500
..selectFiles = [0, 1]
..asDefaultPath = true;
final original = pending.options.toJson();
final members = pending.creationItems();
members.first.opts!
..name = 'changed.txt'
..path = '/elsewhere'
..size = 1
..selectFiles.add(99);
expect(members.last.opts!.name, '1.txt');
expect(members.last.opts!.path, '');
expect(members.last.opts!.size, 101);
expect(members.last.opts!.selectFiles, [0, 1]);
expect(pending.options.toJson(), original);
});
for (final rid in ['', 'resolved-rid']) {
test('empty resource preserves options and selects the creation method for rid="$rid"', () {
final pending = source('empty', rid: rid, count: 0)..selected.add(0);
pending.options
..name = 'custom'
..selectFiles = [2];
final item = pending.creationItems().single;
expect(item.rid, rid.isEmpty ? null : rid);
expect(item.req, rid.isEmpty ? pending.request : null);
expect(identical(item.opts, pending.options), isFalse);
expect(item.opts!.toJson(), pending.options.toJson());
});
}
test('a magnet resolved by an extension keeps its HTTP members and group identity', () {
final pending = source('extension', url: 'magnet:?xt=urn:btih:infohash');
expect(pending.isBt, isFalse);
expect(pending.isGroup, isTrue);
final batch = planDownloads([pending]).single.request;
expect(batch.group, isTrue);
expect(batch.reqs, hasLength(2));
expect(batch.reqs!.every((item) => item.rid == null && item.req!.url.startsWith('https:')), isTrue);
});
test('root names use HTTP file names, BT resource names and URL fallback', () {
final http = source('http', rid: 'http-rid', count: 1, name: 'unused-resource-name');
expect(http.name, '0.txt');
final bt = source('bt', rid: 'bt-rid', count: 1, name: 'torrent', url: 'magnet:?xt=urn:btih:infohash');
bt.result!.res.hash = 'infohash';
expect(bt.isBt, isTrue);
expect(bt.name, 'torrent');
final unnamed = source('empty', rid: 'rid', count: 0, name: '');
expect(unnamed.name, unnamed.request.url);
});
for (final entry in <String, bool>{
'ed2k://|file|payload.bin|4|0123456789ABCDEF0123456789ABCDEF|/': false,
'https://example.com/file': false,
'https://example.com/file.torrent': false,
'ftp://files.example/file.torrent': false,
'ftps://files.example/file.torrent': false,
'FTPES://files.example/file.torrent': false,
'magnet:?xt=urn:btih:infohash': true,
'MAGNET:?xt=urn:btih:infohash': true,
'file:///downloads/file.torrent': true,
'/downloads/FILE.TORRENT': true,
'file:///downloads/file.torrent?query=1': true,
'data:application/x-bittorrent;base64,AAAA': true,
'data:application/octet-stream;base64,AAAA': false,
}.entries) {
test('source protocol determines BT identity for ${entry.key}', () {
final pending = source('protocol', rid: 'rid', count: 1, name: 'resource', url: entry.key);
if (RegExp(r'^ftp', caseSensitive: false).hasMatch(entry.key)) pending.result!.res.name = '';
pending.result!.res.hash = '0123456789ABCDEF0123456789ABCDEF';
expect(pending.isBt, entry.value);
expect(pending.name, entry.value ? 'resource' : '0.txt');
});
}
test('failed resolutions are excluded and each newly opened source resolves fresh', () async {
var calls = 0;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.resolve, (_) {
calls++;
return ResolveResult(
id: 'rid-$calls',
res: Resource(files: [FileInfo(name: 'file')]),
);
});
final first = PendingDownload(
id: 'a',
request: Request(url: 'https://example.com'),
options: Options(),
);
await first.resolve(service(registry));
await first.resolve(service(registry));
expect(calls, 1);
final reopened = PendingDownload(id: 'a', request: first.request, options: Options());
await reopened.resolve(service(registry));
expect(calls, 2);
final failed = PendingDownload(id: 'b', request: first.request, options: Options())..resolveError = 'failed';
expect(planDownloads([failed, reopened]).single.sources, [reopened]);
});
}
@@ -0,0 +1,80 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/model/task.dart' as api;
import 'package:gopeed/core/capabilities/app_capabilities.dart';
import 'package:gopeed/core/capabilities/capability_rpc.dart';
import 'package:gopeed/core/capabilities/gopeed_capability.dart';
import 'package:gopeed/features/tasks/application/tasks_controller.dart';
void main() {
test('the list counts a group once and sends group IDs for every action', () async {
api.Task task(String id) => api.Task.fromJson({
'id': id,
'name': '$id.txt',
'group': {'id': 'group', 'name': 'folder', 'path': '/downloads/folder', 'createdAt': '2026-01-01T00:00:00Z'},
'parentId': 'group',
'protocol': 'http',
'status': 'pause',
'uploading': false,
'createdAt': '2026-01-01T00:00:00Z',
'updatedAt': '2026-01-01T00:00:00Z',
'meta': {
'req': {'url': 'https://example.com/$id'},
'opts': {'path': '/downloads/folder', 'size': 100},
},
'progress': {'used': 0, 'speed': 0, 'downloaded': 25, 'uploaded': 0, 'uploadSpeed': 0},
});
final raw = [task('first'), task('second')];
final actions = <String>[];
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getTasks, (_) => raw)
..bind(GopeedMethods.pauseTask, (id) {
actions.add('pause:$id');
return const RpcUnit();
})
..bind(GopeedMethods.continueTask, (id) {
actions.add('resume:$id');
return const RpcUnit();
})
..bind(GopeedMethods.pauseTasks, (params) {
actions.add('pauseBatch:${params['ids']}');
return const RpcUnit();
})
..bind(GopeedMethods.continueTasks, (params) {
actions.add('resumeBatch:${params['ids']}');
return const RpcUnit();
})
..bind(GopeedMethods.deleteTasks, (params) {
actions.add('delete:${params['ids']}:${params['force']}');
raw.clear();
return const RpcUnit();
});
final container = ProviderContainer(
overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))],
);
addTearDown(container.dispose);
final state = await container.read(tasksControllerProvider.future);
expect(state.tasks.length, 1);
expect(state.tasks.single.members.length, 2);
expect(state.allTasks.map((task) => task.id), ['group', 'first', 'second']);
expect(state.findTask('group'), same(state.tasks.single));
expect(state.findTask('first'), same(state.tasks.single.members.first));
expect(state.findTask('missing'), isNull);
expect(state.tasks.single.matchesSearch('second.txt'), isTrue);
expect(state.tasks.single.progress, 0.25);
final controller = container.read(tasksControllerProvider.notifier);
await controller.pause('group');
await controller.resume('group');
await controller.pauseAll(['group']);
await controller.resumeAll(['group']);
await controller.deleteSelected(['group'], force: true);
expect(actions, [
'pause:group',
'resume:group',
'pauseBatch:[group]',
'resumeBatch:[group]',
'delete:[group]:true',
]);
expect(container.read(tasksControllerProvider).value?.tasks, isEmpty);
});
}
@@ -29,6 +29,219 @@ void main() {
'progress': {'used': 0, 'speed': 0, 'downloaded': 10, 'uploadSpeed': 0, 'uploaded': 0},
});
test('members aggregate through parentId without a parent task', () {
final records = taskRecordsFromApi([
groupTestTask('first', parentId: 'group'),
groupTestTask(
'second',
parentId: 'group',
directory: '/downloads/folder/nested',
name: 'movie.mp4',
size: 300,
downloaded: 75,
),
groupTestTask('standalone'),
]);
expect(records.map((record) => record.id), ['group', 'standalone']);
final record = records.first;
expect(record.isGroup, isTrue);
expect(record.members.length, 2);
expect(record.totalBytes, 400);
expect(record.downloadedBytes, 100);
expect(record.speedBytes, 20);
expect(record.progress, 0.25);
expect(record.storagePath, '/downloads/folder');
expect(record.files.map((file) => file.path), ['', 'nested']);
expect(record.files.last.name, 'movie.mp4');
expect(record.matchesSearch('second'), isTrue);
expect(record.canUpdateUrl, isFalse);
});
test('group preserves uploading while member upload speed is momentarily zero', () {
final member = groupTestTask('uploading', parentId: 'group');
member.uploading = true;
member.progress.uploadSpeed = 0;
final group = taskRecordsFromApi([member]).single;
expect(group.uploading, isTrue);
expect(group.uploadSpeedBytes, 0);
});
for (final scenario in <(List<String>, TaskStatus, bool)>[
(['running', 'error', 'pause'], TaskStatus.downloading, false),
(['error', 'pause', 'wait'], TaskStatus.failed, false),
(['pause', 'wait', 'done'], TaskStatus.paused, false),
(['done', 'done'], TaskStatus.completed, false),
(['wait', 'done'], TaskStatus.downloading, true),
]) {
test('group status priority for ${scenario.$1}', () {
final record = taskRecordsFromApi([
for (var i = 0; i < scenario.$1.length; i++)
groupTestTask('child-$i', parentId: 'group', status: scenario.$1[i]),
]).single;
expect(record.status, scenario.$2);
expect(record.waiting, scenario.$3);
if (scenario.$2 == TaskStatus.completed) {
expect(record.progress, 1);
expect(record.doneAt, DateTime.utc(2026, 1, 3));
}
});
}
test('unknown member sizes stay indeterminate and empty files can finish', () {
final unknown = groupTestTask('unknown', parentId: 'group', size: 0);
final record = taskRecordsFromApi([unknown, groupTestTask('known', parentId: 'group')]).single;
expect(record.totalBytes, isNull);
expect(record.progress, isNull);
unknown.status = api.Status.done;
final empty = taskRecordsFromApi([unknown]).single;
expect(empty.totalBytes, 0);
expect(empty.progress, 1);
});
test('Windows group directories retain their relative file tree', () {
final records = taskRecordsFromApi([
groupTestTask('child', parentId: 'group', directory: r'C:\Downloads\folder\nested'),
]);
expect(records.single.files.single.path, 'nested');
expect(records.single.files.single.name, 'file.txt');
});
test('folder member file progress spreads the member ratio over its files', () {
final member = api.Task.fromJson({
'id': 'pack',
'name': 'pack',
'parentId': 'group',
'group': {'id': 'group', 'name': 'folder', 'path': '/downloads/folder', 'createdAt': '2026-01-01T00:00:00Z'},
'protocol': 'http',
'status': 'running',
'uploading': false,
'createdAt': '2026-01-01T00:00:00Z',
'updatedAt': '2026-01-02T00:00:00Z',
'doneAt': null,
'meta': {
'req': {'url': 'https://example.com/pack'},
'opts': {'path': '/downloads/folder/pack', 'name': 'pack', 'size': 30},
'res': {
'name': 'pack',
'size': 30,
'files': [
{'name': 'a.bin', 'size': 10},
{'name': 'b.bin', 'size': 20},
],
},
},
'progress': {'used': 0, 'speed': 5, 'downloaded': 15, 'uploadSpeed': 0, 'uploaded': 0},
});
final record = taskRecordsFromApi([member]).single;
expect(record.files.map((file) => file.name), ['a.bin', 'b.bin']);
expect(record.files.first.downloadedBytes, 5);
expect(record.files.last.downloadedBytes, 10);
});
test('members outside the group root render under their own folder name', () {
final folderMember = api.Task.fromJson({
'id': 'pack',
'name': 'pack',
'parentId': 'group',
'group': {'id': 'group', 'name': 'folder', 'path': '/downloads/folder', 'createdAt': '2026-01-01T00:00:00Z'},
'protocol': 'http',
'status': 'running',
'uploading': false,
'createdAt': '2026-01-01T00:00:00Z',
'updatedAt': '2026-01-02T00:00:00Z',
'doneAt': null,
'meta': {
'req': {'url': 'https://example.com/pack'},
'opts': {'path': '/elsewhere/pack', 'name': 'pack', 'size': 30},
'res': {
'name': 'pack',
'size': 30,
'files': [
{'name': 'a.bin', 'size': 30},
],
},
},
'progress': {'used': 0, 'speed': 5, 'downloaded': 15, 'uploadSpeed': 0, 'uploaded': 0},
});
final record = taskRecordsFromApi([
groupTestTask('loose', parentId: 'group', directory: '/elsewhere'),
folderMember,
]).single;
for (final file in record.files) {
expect(file.path.startsWith('..'), isFalse);
}
expect(record.files.first.path, '');
expect(record.files.first.name, 'file.txt');
expect(record.files.last.path, 'pack');
expect(record.files.last.name, 'a.bin');
});
test('completed group reports the wall span from creation to finish', () {
final record = taskRecordsFromApi([groupTestTask('child', parentId: 'group', status: 'done')]).single;
expect(record.downloadDuration, const Duration(days: 2));
});
for (final completed in [false, true]) {
test('group timestamps use chronological maxima when completed=$completed', () {
final first = groupTestTask('first', parentId: 'group', status: completed ? 'done' : 'running')
..updatedAt = DateTime.utc(2026, 1, 6)
..doneAt = DateTime.utc(2026, 1, 5);
final second = groupTestTask('second', parentId: 'group', status: 'done')
..updatedAt = DateTime.utc(2026, 1, 3)
..doneAt = DateTime.utc(2026, 1, 4);
final legacy = groupTestTask('legacy', parentId: 'group', status: 'done')..doneAt = null;
final record = taskRecordsFromApi([first, second, legacy]).single;
expect(record.updatedAt, DateTime.utc(2026, 1, 6));
expect(record.doneAt, completed ? DateTime.utc(2026, 1, 5) : null);
expect(record.downloadDuration, completed ? const Duration(days: 4) : null);
});
}
test('group creation bounds updated time and legacy completion stays unknown', () {
final member = groupTestTask('legacy', parentId: 'group', status: 'done')..doneAt = null;
member.group = api.TaskGroup(
id: 'group',
name: 'folder',
path: '/downloads/folder',
createdAt: DateTime.utc(2026, 1, 4),
);
final record = taskRecordsFromApi([member]).single;
expect(record.updatedAt, member.group!.createdAt);
expect(record.doneAt, isNull);
expect(record.downloadDuration, isNull);
});
test('completed group reports the members average speed instead of 0 B/s', () {
final record = taskRecordsFromApi([
groupTestTask(
'first',
parentId: 'group',
status: 'done',
size: 100,
downloaded: 100,
usedNanoseconds: 10 * 1000000000,
),
groupTestTask(
'second',
parentId: 'group',
status: 'done',
size: 50,
downloaded: 50,
usedNanoseconds: 5 * 1000000000,
),
]).single;
// 150 bytes over 15 combined active seconds, not the 0 of the idle aggregate.
expect(record.speedBytes, 10);
expect(record.speed, '10 B/s');
});
test('paused group keeps the idle aggregate instead of an average speed', () {
final record = taskRecordsFromApi([
groupTestTask('first', parentId: 'group', status: 'pause', downloaded: 100, usedNanoseconds: 10 * 1000000000),
]).single;
expect(record.speedBytes, 0);
});
test('selected files retain original indexes for runtime progress', () {
final record = TaskRecord.fromApi(buildTask([2, 0], 40));
expect(record.files.map((file) => file.name), ['a.txt', 'c.txt']);
@@ -97,3 +310,43 @@ void main() {
expect(taskFileTypeIcon('Playlist #2.mp4', isFolder: true), GopeedIcons.folder);
});
}
api.Task groupTestTask(
String id, {
String parentId = '',
String status = 'running',
int size = 100,
int downloaded = 25,
int usedNanoseconds = 0,
String directory = '/downloads/folder',
String name = 'file.txt',
}) => api.Task.fromJson({
'id': id,
'name': name,
'parentId': parentId,
if (parentId.isNotEmpty)
'group': {
'id': parentId,
'name': 'folder',
'path': directory.contains('\\') ? r'C:\Downloads\folder' : '/downloads/folder',
'createdAt': '2026-01-01T00:00:00Z',
},
'protocol': 'http',
'status': status,
'uploading': false,
'createdAt': '2026-01-01T00:00:00Z',
'updatedAt': '2026-01-02T00:00:00Z',
'doneAt': status == 'done' ? '2026-01-03T00:00:00Z' : null,
'meta': {
'req': {'url': 'https://example.com/$id'},
'opts': {'path': directory, 'name': name, 'size': size},
if (size > 0)
'res': {
'size': size,
'files': [
{'name': name, 'size': size},
],
},
},
'progress': {'used': usedNanoseconds, 'speed': 10, 'downloaded': downloaded, 'uploadSpeed': 0, 'uploaded': 0},
});
@@ -0,0 +1,445 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:gopeed/api/model/create_task_batch.dart';
import 'package:gopeed/api/model/create_task.dart';
import 'package:gopeed/api/model/request.dart';
import 'package:gopeed/api/model/downloader_config.dart';
import 'package:gopeed/api/model/resource.dart';
import 'package:gopeed/api/model/resolve_result.dart';
import 'package:gopeed/core/capabilities/app_capabilities.dart';
import 'package:gopeed/core/capabilities/capability_rpc.dart';
import 'package:gopeed/core/capabilities/gopeed_capability.dart';
import 'package:gopeed/core/capabilities/storage_capability.dart';
import 'package:gopeed/features/tasks/presentation/pages/create_task_window_page.dart';
import 'package:gopeed/features/tasks/application/pending_create_task.dart';
import 'package:gopeed/shared/theme/app_component_themes.dart';
import 'package:gopeed/shared/theme/app_theme.dart';
import 'package:gopeed/shared/widgets/app_http_headers_editor.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
void main() {
testWidgets('cancel and confirm again resolves every unsubmitted URL afresh', (tester) async {
tester.view.physicalSize = const Size(900, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final resolved = <String>[];
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit())
..bind(GopeedMethods.resolve, (request) {
resolved.add(request.req!.url);
return ResolveResult(
id: 'rid-${resolved.length}',
res: Resource(files: [FileInfo(name: 'file')]),
);
});
await tester.pumpWidget(
ProviderScope(
overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))],
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: AppComponentThemes(child: const CreateTaskWindowPage()),
),
),
);
await tester.pumpAndSettle();
final url = find.byKey(const ValueKey('create-task-url-input'));
await tester.enterText(url, 'https://example.com/a\nhttps://example.com/b');
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(resolved, ['https://example.com/a', 'https://example.com/b']);
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(resolved, [
'https://example.com/a',
'https://example.com/b',
'https://example.com/a',
'https://example.com/b',
]);
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
for (final failGroup in [true, false]) {
testWidgets('reopening a preview retries only the failed batch (group=$failGroup)', (tester) async {
final resolutions = <String>[];
final batches = <CreateTaskBatch>[];
var fail = true;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.resolve, (request) {
resolutions.add(request.req!.url);
return _resolved(request.req!.url);
})
..bind(GopeedMethods.createTaskBatch, (batch) {
batches.add(batch);
if (batch.group == failGroup && fail) throw StateError('batch failed');
return CreateTaskBatchResult(taskIds: ['created']);
});
await _openPage(tester, registry);
await _openPreview(tester, urls: 'https://example.com/group\nhttps://example.com/other');
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(batches, hasLength(2));
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
await tester.pumpAndSettle();
fail = false;
await _openPreview(tester);
final failedUrl = 'https://example.com/${failGroup ? 'group' : 'other'}';
expect(resolutions, ['https://example.com/group', 'https://example.com/other', failedUrl]);
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(batches, hasLength(3));
expect(batches.where((batch) => batch.group != failGroup), hasLength(1));
expect(find.byType(CreateTaskWindowPage), findsNothing);
expect(tester.takeException(), isNull);
});
}
testWidgets('completion survives reordering and distinguishes duplicate source occurrences', (tester) async {
final resolutions = <String>[];
var groupCalls = 0;
var fail = true;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.resolve, (request) {
resolutions.add(request.req!.url);
return _resolved(request.req!.url);
})
..bind(GopeedMethods.createTaskBatch, (batch) {
if (batch.group) {
groupCalls++;
if (groupCalls == 2 && fail) throw StateError('second occurrence failed');
}
return CreateTaskBatchResult(taskIds: ['created']);
});
await _openPage(tester, registry);
await _openPreview(tester, urls: 'https://example.com/group\nhttps://example.com/other\nhttps://example.com/group');
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
await tester.pumpAndSettle();
fail = false;
await _openPreview(tester, urls: 'https://example.com/other\nhttps://example.com/group\nhttps://example.com/group');
expect(resolutions, [
'https://example.com/group',
'https://example.com/other',
'https://example.com/group',
'https://example.com/group',
]);
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(groupCalls, 3);
expect(find.byType(CreateTaskWindowPage), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('unfinished sources resolve with edited options without recreating a completed group', (tester) async {
final resolutions = <Request>[];
final batches = <CreateTaskBatch>[];
var fail = true;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.resolve, (request) {
resolutions.add(request.req!);
return _resolved(request.req!.url);
})
..bind(GopeedMethods.createTaskBatch, (batch) {
batches.add(batch);
if (!batch.group && fail) throw StateError('batch failed');
return CreateTaskBatchResult(taskIds: ['created']);
});
await _openPage(tester, registry);
await _openPreview(tester, urls: 'https://example.com/group\nhttps://example.com/other');
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
await tester.pumpAndSettle();
await tester.tap(find.text('Advanced'));
await tester.pumpAndSettle();
tester.widget<AppHttpHeadersEditor>(find.byType(AppHttpHeadersEditor)).controller.replace({'Cookie': 'new-cookie'});
await tester.enterText(find.byKey(const ValueKey('create-task-directory-input')), '/retry');
fail = false;
await _openPreview(tester);
expect(resolutions.map((request) => request.url), [
'https://example.com/group',
'https://example.com/other',
'https://example.com/other',
]);
expect(ReqExtraHttp.fromJson(resolutions.last.extra! as Map<String, dynamic>).header['Cookie'], 'new-cookie');
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(batches.where((batch) => batch.group), hasLength(1));
expect(batches.last.reqs!.single.opts!.path, '/retry');
expect(tester.takeException(), isNull);
});
testWidgets('a new creation window can submit the same source again', (tester) async {
var resolutions = 0;
var submissions = 0;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.resolve, (request) {
resolutions++;
return _resolved(request.req!.url);
})
..bind(GopeedMethods.createTaskBatch, (_) {
submissions++;
return CreateTaskBatchResult(taskIds: ['created']);
});
final router = await _openPage(tester, registry);
for (var attempt = 0; attempt < 2; attempt++) {
if (attempt > 0) {
router.go('/create');
await tester.pumpAndSettle();
}
await _openPreview(tester, urls: 'https://example.com/group');
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.byType(CreateTaskWindowPage), findsNothing);
}
expect(resolutions, 2);
expect(submissions, 2);
expect(tester.takeException(), isNull);
});
for (final duringSubmission in [false, true]) {
testWidgets('a new external task starts a fresh session (during submission: $duringSubmission)', (tester) async {
final firstSubmission = Completer<CreateTaskBatchResult>();
final resolutions = <Request>[];
var groupSubmissions = 0;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.resolve, (request) {
final req = request.req!;
resolutions.add(req);
return _resolved(req.url);
})
..bind(GopeedMethods.createTaskBatch, (batch) {
if (batch.group) {
if (++groupSubmissions == 1 && duringSubmission) return firstSubmission.future;
} else if (!duringSubmission) {
throw StateError('ordinary batch failed');
}
return CreateTaskBatchResult(taskIds: ['created']);
});
await _openPage(tester, registry);
final page = find.byType(CreateTaskWindowPage);
final pageState = tester.state(page);
final container = ProviderScope.containerOf(tester.element(page));
await _openPreview(tester, urls: 'https://example.com/group\nhttps://example.com/other');
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(groupSubmissions, 1);
if (!duringSubmission) {
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
await tester.pumpAndSettle();
}
container
.read(pendingCreateTaskProvider.notifier)
.set(
CreateTask(
req: Request(
url: 'https://example.com/group',
extra: ReqExtraHttp(method: 'GET', header: {'Cookie': 'new-intent'}, body: '').toJson(),
),
),
);
await tester.pump();
if (duringSubmission) {
firstSubmission.complete(CreateTaskBatchResult(taskIds: ['old-created']));
await tester.pump();
}
await tester.pumpAndSettle();
expect(tester.state(page), same(pageState));
await _openPreview(tester);
expect(resolutions.map((request) => request.url), [
'https://example.com/group',
'https://example.com/other',
'https://example.com/group',
]);
expect(ReqExtraHttp.fromJson(resolutions.last.extra! as Map<String, dynamic>).header['Cookie'], 'new-intent');
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(groupSubmissions, 2);
expect(page, findsNothing);
expect(tester.takeException(), isNull);
});
}
testWidgets('a failed stale direct submission releases the new task form', (tester) async {
final firstSubmission = Completer<CreateTaskBatchResult>();
final requests = <CreateTaskBatch>[];
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.createTaskBatch, (batch) {
requests.add(batch);
if (requests.length == 1) return firstSubmission.future;
return CreateTaskBatchResult(taskIds: ['new-created']);
});
await _openPage(tester, registry);
final container = ProviderScope.containerOf(tester.element(find.byType(CreateTaskWindowPage)));
await tester.tap(find.byKey(const ValueKey('create-task-direct-download-checkbox')));
await tester.enterText(find.byKey(const ValueKey('create-task-url-input')), 'https://example.com/old');
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
await tester.pump();
expect(requests, hasLength(1));
container.read(pendingCreateTaskProvider.notifier).set(CreateTask(req: Request(url: 'https://example.com/new')));
await tester.pump();
firstSubmission.completeError(StateError('old submission failed'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.byType(CreateTaskWindowPage), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(requests, hasLength(2));
expect(requests.last.reqs!.single.req!.url, 'https://example.com/new');
expect(find.byType(CreateTaskWindowPage), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('confirming only completed sources finishes without opening another preview', (tester) async {
var resolutions = 0;
var submissions = 0;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.resolve, (request) {
resolutions++;
return _resolved(request.req!.url);
})
..bind(GopeedMethods.createTaskBatch, (batch) {
submissions++;
if (!batch.group) throw StateError('batch failed');
return CreateTaskBatchResult(taskIds: ['created']);
});
await _openPage(tester, registry);
await _openPreview(tester, urls: 'https://example.com/group\nhttps://example.com/other');
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
await tester.pumpAndSettle();
await tester.enterText(find.byKey(const ValueKey('create-task-url-input')), 'https://example.com/group');
await tester.tap(find.text('Advanced'));
await tester.pumpAndSettle();
await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), 'invalid');
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(resolutions, 2);
expect(submissions, 2);
expect(find.byType(CreateTaskWindowPage), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('switching to direct download skips completed preview batches', (tester) async {
var resolutions = 0;
final batches = <CreateTaskBatch>[];
var fail = true;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.resolve, (request) {
resolutions++;
return _resolved(request.req!.url);
})
..bind(GopeedMethods.createTaskBatch, (batch) {
batches.add(batch);
if (!batch.group && fail) throw StateError('batch failed');
return CreateTaskBatchResult(taskIds: ['created']);
});
await _openPage(tester, registry);
await _openPreview(tester, urls: 'https://example.com/group\nhttps://example.com/other');
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('create-task-direct-download-checkbox')));
await tester.pump();
fail = false;
await _openPreview(tester);
expect(resolutions, 2);
expect(batches, hasLength(3));
expect(batches.last.reqs!.single.req!.url, 'https://example.com/other');
expect(find.byType(CreateTaskWindowPage), findsNothing);
expect(tester.takeException(), isNull);
});
}
ResolveResult _resolved(String url) => url.endsWith('/group')
? ResolveResult(
res: Resource(
name: 'album',
files: [
FileInfo(
name: 'a.txt',
req: Request(url: 'https://example.com/a.txt'),
),
FileInfo(
name: 'b.txt',
req: Request(url: 'https://example.com/b.txt'),
),
],
),
)
: ResolveResult(
id: 'rid-$url',
res: Resource(files: [FileInfo(name: 'other.txt')]),
);
Future<GoRouter> _openPage(WidgetTester tester, CapabilityRegistry registry) async {
tester.view.physicalSize = const Size(900, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
registry
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
final router = GoRouter(
initialLocation: '/create',
routes: [
GoRoute(path: '/', builder: (_, _) => const SizedBox()),
GoRoute(path: '/create', builder: (_, _) => const CreateTaskWindowPage()),
],
);
addTearDown(router.dispose);
await tester.pumpWidget(
ProviderScope(
overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))],
child: shad.ShadcnApp.router(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
routerConfig: router,
builder: (_, child) => AppComponentThemes(child: child!),
),
),
);
await tester.pumpAndSettle();
return router;
}
Future<void> _openPreview(WidgetTester tester, {String? urls}) async {
if (urls != null) await tester.enterText(find.byKey(const ValueKey('create-task-url-input')), urls);
final confirm = find.byKey(const ValueKey('create-task-confirm-button'));
await tester.ensureVisible(confirm);
await tester.tap(confirm);
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
}
@@ -36,7 +36,7 @@ import 'package:gopeed/l10n/app_localizations.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
void main() {
testWidgets('extension file estimates survive batch submission and window serialization', (tester) async {
testWidgets('extension file paths and estimates survive batch submission and window serialization', (tester) async {
tester.view.physicalSize = const Size(900, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
@@ -49,6 +49,7 @@ void main() {
(_) => ResolveResult(
id: '',
res: Resource(
name: 'videos',
files: [
FileInfo(
name: 'under.mp4',
@@ -57,6 +58,7 @@ void main() {
),
FileInfo(
name: 'over.mp4',
path: 'nested',
size: 1048576,
req: Request(url: 'https://example.com/audio'),
),
@@ -66,7 +68,7 @@ void main() {
)
..bind(GopeedMethods.createTaskBatch, (batch) {
submitted = CreateTaskBatch.fromJson(jsonDecode(jsonEncode(batch)) as Map<String, dynamic>);
return ['first', 'second'];
return CreateTaskBatchResult(taskIds: ['first', 'second'], parentId: 'generated');
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
final router = GoRouter(
@@ -97,6 +99,10 @@ void main() {
await tester.pumpAndSettle();
expect(submitted?.reqs?.map((item) => item.opts?.size), [3, 1048576]);
expect(submitted?.reqs?.map((item) => item.req?.url), ['https://example.com/video', 'https://example.com/audio']);
expect(submitted?.group, isTrue);
expect(submitted?.groupName, 'videos');
expect(submitted?.opts?.path, '/downloads');
expect(submitted?.reqs?.map((item) => item.opts?.path), ['', 'nested']);
expect(tester.takeException(), isNull);
});
@@ -118,9 +124,9 @@ void main() {
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => config)
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit())
..bind(GopeedMethods.createTask, (_) {
..bind(GopeedMethods.createTaskBatch, (batch) {
events.add('created');
return 'task-id';
return CreateTaskBatchResult(taskIds: ['task-id']);
})
..bind(NavigationMethods.showDownloadingTasks, (_) {
events.add('downloads');
@@ -163,9 +169,14 @@ void main() {
CreateTask? submitted;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
..bind(GopeedMethods.createTask, (task) {
..bind(GopeedMethods.createTaskBatch, (batch) {
final task = CreateTask(
rid: batch.reqs!.single.rid,
req: batch.reqs!.single.req,
opts: batch.reqs!.single.opts,
);
submitted = task;
return 'created-id';
return CreateTaskBatchResult(taskIds: ['created-id']);
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
final container = ProviderContainer(
@@ -246,9 +257,14 @@ void main() {
GopeedMethods.getConfig,
(_) => DownloaderConfig(downloadDir: '/downloads')..extra.defaultDirectDownload = true,
)
..bind(GopeedMethods.createTask, (task) {
..bind(GopeedMethods.createTaskBatch, (batch) {
final task = CreateTask(
rid: batch.reqs!.single.rid,
req: batch.reqs!.single.req,
opts: batch.reqs!.single.opts,
);
submitted = task;
return 'created-id';
return CreateTaskBatchResult(taskIds: ['created-id']);
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
final container = ProviderContainer(
@@ -374,9 +390,15 @@ void main() {
..protocolConfig.http.connections = 8;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => config)
..bind(GopeedMethods.createTask, (task) {
..bind(GopeedMethods.putConfig, (_) => const RpcUnit())
..bind(GopeedMethods.createTaskBatch, (batch) {
final task = CreateTask(
rid: batch.reqs!.single.rid,
req: batch.reqs!.single.req,
opts: batch.reqs!.single.opts,
);
submitted = task;
return 'created-task';
return CreateTaskBatchResult(taskIds: ['created-task']);
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
final capabilities = AppCapabilities(LocalCapabilityInvoker(registry));
@@ -877,9 +899,14 @@ void main() {
CreateTask? submitted;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
..bind(GopeedMethods.createTask, (task) {
..bind(GopeedMethods.createTaskBatch, (batch) {
final task = CreateTask(
rid: batch.reqs!.single.rid,
req: batch.reqs!.single.req,
opts: batch.reqs!.single.opts,
);
submitted = task;
return 'task-id';
return CreateTaskBatchResult(taskIds: ['task-id']);
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
@@ -985,9 +1012,14 @@ void main() {
final config = DownloaderConfig(downloadDir: '/downloads')..extra.defaultDirectDownload = true;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => config)
..bind(GopeedMethods.createTask, (task) {
..bind(GopeedMethods.createTaskBatch, (batch) {
final task = CreateTask(
rid: batch.reqs!.single.rid,
req: batch.reqs!.single.req,
opts: batch.reqs!.single.opts,
);
submitted = task;
return 'task-id';
return CreateTaskBatchResult(taskIds: ['task-id']);
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
@@ -1140,9 +1172,18 @@ void main() {
final createdTasks = <CreateTask>[];
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
..bind(GopeedMethods.createTask, (task) {
createdTasks.add(task);
return 'task-id';
..bind(
GopeedMethods.resolve,
(_) => ResolveResult(
id: 'resolved',
res: Resource(files: [FileInfo(name: 'file.zip')]),
),
)
..bind(GopeedMethods.createTaskBatch, (batch) {
for (final item in batch.reqs!) {
createdTasks.add(CreateTask(rid: item.rid, req: item.req, opts: item.opts));
}
return CreateTaskBatchResult(taskIds: ['task-id']);
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
@@ -1189,8 +1230,12 @@ void main() {
// Confirm creation
await tester.tap(find.text('Confirm'));
await tester.pumpAndSettle();
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(createdTasks, isEmpty);
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pumpAndSettle();
expect(createdTasks.length, 2);
for (final t in createdTasks) {
final extra = OptsExtraHttp.fromJson(Map<String, dynamic>.from(t.opts!.extra! as Map));
@@ -1345,7 +1390,7 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets('the resolve dialog shows the suggested category directory', (tester) async {
testWidgets('the file list keeps category suggestions out of its header', (tester) async {
tester.view.physicalSize = const Size(700, 700);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
@@ -1381,8 +1426,8 @@ void main() {
await tester.pump(const Duration(milliseconds: 300));
final suggestion = find.byKey(const ValueKey('resolve-category-directory'));
expect(suggestion, findsOneWidget);
expect(tester.widget<Text>(suggestion).data, contains('/downloads/Program'));
expect(suggestion, findsNothing);
expect(find.text('File List'), findsOneWidget);
expect(tester.takeException(), isNull);
});
@@ -1437,7 +1482,7 @@ void main() {
await _settleToastTimers(tester);
});
testWidgets('the resolve dialog shows the directory the user named, not the suggestion', (tester) async {
testWidgets('the file list uses its fixed title for a manual directory', (tester) async {
tester.view.physicalSize = const Size(700, 700);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
@@ -1475,13 +1520,87 @@ void main() {
await tester.pump(const Duration(milliseconds: 300));
final shown = find.byKey(const ValueKey('resolve-category-directory'));
expect(shown, findsOneWidget);
expect(tester.widget<Text>(shown).data, contains('/work'));
expect(tester.widget<Text>(shown).data, isNot(contains('/downloads/Program')));
expect(shown, findsNothing);
expect(find.text('File List'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('a resolved file list is split into tasks that keep the directory structure', (tester) async {
testWidgets('a resolved file list keeps structure and saves the selected parent directory', (tester) async {
tester.view.physicalSize = const Size(700, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
CreateTaskBatch? batch;
final config = DownloaderConfig(downloadDir: '/downloads');
DownloaderConfig? savedConfig;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => config)
..bind(GopeedMethods.putConfig, (next) {
savedConfig = next;
return const RpcUnit();
})
..bind(
GopeedMethods.resolve,
(_) => ResolveResult(
res: Resource(
name: 'bundle',
files: [
FileInfo(name: 'first.zip', path: 'first.zip', size: 1),
FileInfo(name: 'second.zip', path: 'second.zip', size: 1),
],
),
),
)
..bind(GopeedMethods.createTaskBatch, (request) {
batch = request;
return CreateTaskBatchResult(taskIds: ['task-1', 'task-2'], parentId: 'generated');
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
final capabilities = AppCapabilities(LocalCapabilityInvoker(registry));
await tester.pumpWidget(
ProviderScope(
overrides: [appCapabilitiesProvider.overrideWithValue(capabilities)],
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: AppComponentThemes(child: const CreateTaskWindowPage()),
),
),
);
await tester.pumpAndSettle();
await tester.enterText(find.byKey(const ValueKey('create-task-directory-input')), '/chosen');
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('create-task-remember-download-directory-checkbox')));
await tester.pumpAndSettle();
await tester.enterText(find.byKey(const ValueKey('create-task-url-input')), 'https://example.com/bundle');
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pumpAndSettle();
expect(batch, isNotNull);
expect(batch!.reqs, hasLength(2));
expect(batch!.group, isTrue);
expect(batch!.groupName, 'bundle');
expect(batch!.toJson().containsKey('groupId'), isFalse);
expect(batch!.opts?.path, '/chosen');
expect(batch!.opts?.asDefaultPath, isTrue);
for (final item in batch!.reqs!) {
// The split tasks keep the structure of the resolved resource, so they
// are never classified into a category or replace the selected parent.
expect(item.opts?.skipAutoCategorize, isTrue);
expect(item.opts?.asDefaultPath, isFalse);
}
expect(savedConfig?.downloadDir, '/chosen');
expect(batch!.group, isTrue);
expect(tester.takeException(), isNull);
await _settleToastTimers(tester);
});
testWidgets('a single selected file of a multi file resource is still not classified', (tester) async {
tester.view.physicalSize = const Size(700, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
@@ -1504,64 +1623,7 @@ void main() {
)
..bind(GopeedMethods.createTaskBatch, (request) {
batch = request;
return ['task-1', 'task-2'];
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
final capabilities = AppCapabilities(LocalCapabilityInvoker(registry));
await tester.pumpWidget(
ProviderScope(
overrides: [appCapabilitiesProvider.overrideWithValue(capabilities)],
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: AppComponentThemes(child: const CreateTaskWindowPage()),
),
),
);
await tester.pumpAndSettle();
await tester.enterText(find.byKey(const ValueKey('create-task-url-input')), 'https://example.com/bundle');
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pumpAndSettle();
expect(batch, isNotNull);
expect(batch!.reqs, hasLength(2));
for (final item in batch!.reqs!) {
// The split tasks keep the structure of the resolved resource, so they
// are never classified into a category.
expect(item.opts?.skipAutoCategorize, isTrue);
}
expect(batch!.opts?.skipAutoCategorize, isTrue);
expect(tester.takeException(), isNull);
await _settleToastTimers(tester);
});
testWidgets('a single selected file of a multi file resource is still not classified', (tester) async {
tester.view.physicalSize = const Size(700, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
CreateTaskBatch? batch;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
..bind(
GopeedMethods.resolve,
(_) => ResolveResult(
res: Resource(
files: [
FileInfo(name: 'first.zip', path: 'first.zip', size: 1),
FileInfo(name: 'second.zip', path: 'second.zip', size: 1),
],
),
),
)
..bind(GopeedMethods.createTaskBatch, (request) {
batch = request;
return ['task-1'];
return CreateTaskBatchResult(taskIds: ['task-1'], parentId: 'generated');
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
final capabilities = AppCapabilities(LocalCapabilityInvoker(registry));
@@ -1594,10 +1656,11 @@ void main() {
expect(batch, isNotNull);
expect(batch!.reqs, hasLength(1));
expect(batch!.group, isTrue);
for (final item in batch!.reqs!) {
expect(item.opts?.skipAutoCategorize, isTrue);
}
expect(batch!.opts?.skipAutoCategorize, isTrue);
expect(batch!.group, isTrue);
expect(tester.takeException(), isNull);
await _settleToastTimers(tester);
});
@@ -1642,7 +1705,7 @@ void _ftpTests() {
testWidgets('ftp links offer the TLS mode and carry FTP options through resolve', (tester) async {
ResolveTask? resolved;
CreateTask? submitted;
CreateTaskBatch? submitted;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
..bind(GopeedMethods.resolve, (task) {
@@ -1655,9 +1718,9 @@ void _ftpTests() {
),
);
})
..bind(GopeedMethods.createTask, (task) {
submitted = task;
return 'ftp-task';
..bind(GopeedMethods.createTaskBatch, (batch) {
submitted = batch;
return CreateTaskBatchResult(taskIds: ['ftp-task']);
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
await _pumpCreateRoute(tester, registry);
@@ -1693,20 +1756,20 @@ void _ftpTests() {
// The TLS mode travels on the request, so the resolve already uses it.
expect(resolved?.req?.url, 'FTP://files.example/pub/big.iso');
expect(resolved?.req?.extra, {'tls': 'explicit'});
expect(submitted?.rid, 'ftp-resolved');
final createOptions = submitted?.opts?.extra! as Map<String, dynamic>;
expect(submitted?.reqs?.single.rid, 'ftp-resolved');
final createOptions = submitted?.reqs?.single.opts?.extra! as Map<String, dynamic>;
expect(createOptions['connections'], 32);
expect(createOptions.containsKey('tls'), isFalse);
});
testWidgets('an ftp .torrent link restores its TLS mode and is not treated as BitTorrent', (tester) async {
CreateTask? submitted;
CreateTaskBatch? submitted;
final config = DownloaderConfig(downloadDir: '/downloads')..extra.defaultDirectDownload = true;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => config)
..bind(GopeedMethods.createTask, (task) {
submitted = task;
return 'ftp-task';
..bind(GopeedMethods.createTaskBatch, (batch) {
submitted = batch;
return CreateTaskBatchResult(taskIds: ['ftp-task']);
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
await _pumpCreateRoute(
@@ -1733,19 +1796,22 @@ void _ftpTests() {
await tester.tap(find.text('Confirm'));
await tester.pumpAndSettle();
expect(submitted?.req?.url, 'ftp://files.example/pub/linux.torrent');
expect(submitted?.req?.extra, {'tls': 'implicit'});
final options = OptsExtraFtp.fromJson(submitted?.opts?.extra! as Map<String, dynamic>);
expect(submitted?.reqs?.single.req?.url, 'ftp://files.example/pub/linux.torrent');
expect(submitted?.reqs?.single.req?.extra, {'tls': 'implicit'});
final options = OptsExtraFtp.fromJson(submitted?.reqs?.single.opts?.extra! as Map<String, dynamic>);
expect(options.connections, 6);
});
testWidgets('mixed links cap only the FTP task at 32 connections', (tester) async {
final submitted = <CreateTask>[];
final submitted = <CreateTaskBatch>[];
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
..bind(GopeedMethods.createTask, (task) {
submitted.add(task);
return 'task-${submitted.length}';
..bind(
GopeedMethods.getConfig,
(_) => DownloaderConfig(downloadDir: '/downloads')..extra.defaultDirectDownload = true,
)
..bind(GopeedMethods.createTaskBatch, (batch) {
submitted.add(batch);
return CreateTaskBatchResult(taskIds: ['http-task', 'ftp-task']);
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
await _pumpCreateRoute(tester, registry);
@@ -1758,13 +1824,60 @@ void _ftpTests() {
await tester.tap(find.text('Confirm'));
await tester.pumpAndSettle();
final byUrl = {for (final task in submitted) task.req!.url: task.opts!.extra! as Map<String, dynamic>};
expect(submitted, hasLength(1));
final byUrl = {for (final item in submitted.single.reqs!) item.req!.url: item.opts!.extra! as Map<String, dynamic>};
expect(byUrl.keys, unorderedEquals(['https://example.com/a.zip', 'ftpes://files.example/b.iso']));
expect(byUrl['https://example.com/a.zip']!['connections'], 64);
expect(byUrl['https://example.com/a.zip']!.containsKey('tls'), isFalse);
expect(byUrl['ftpes://files.example/b.iso']!['connections'], 32);
expect(byUrl['ftpes://files.example/b.iso']!.containsKey('tls'), isFalse);
expect(submitted.map((task) => task.req!.extra), everyElement(isNull));
expect(submitted.single.reqs!.map((item) => item.req!.extra), everyElement(isNull));
});
testWidgets('mixed resolved HTTP and FTP resources share one batch with separate options', (tester) async {
final resolvedUrls = <String>[];
CreateTaskBatch? submitted;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
..bind(GopeedMethods.resolve, (task) {
final url = task.req!.url;
resolvedUrls.add(url);
final ftp = url.startsWith('ftp');
return ResolveResult(
id: ftp ? 'ftp-rid' : 'http-rid',
res: Resource(
name: ftp ? 'folder' : '',
files: [
FileInfo(name: ftp ? 'movie.mkv' : 'file.zip', size: 10),
if (ftp) FileInfo(name: 'extra.txt', size: 2),
],
),
);
})
..bind(GopeedMethods.createTaskBatch, (batch) {
submitted = batch;
return CreateTaskBatchResult(taskIds: ['http-task', 'ftp-task']);
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
await _pumpCreateRoute(tester, registry);
await tester.enterText(find.byKey(urlInput), 'https://example.com/a.zip\nftpes://files.example/folder');
await tester.enterText(find.byKey(connectionsInput), '64');
await tester.tap(find.text('Confirm'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(resolvedUrls, ['https://example.com/a.zip', 'ftpes://files.example/folder']);
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pumpAndSettle();
expect(submitted!.group, isFalse);
expect(submitted!.reqs, hasLength(2));
final http = submitted!.reqs![0];
final ftp = submitted!.reqs![1];
expect(http.rid, 'http-rid');
expect(ftp.rid, 'ftp-rid');
expect((http.opts!.extra as Map)['connections'], 64);
expect((ftp.opts!.extra as Map)['connections'], 32);
expect(http.opts!.selectFiles, [0]);
expect(ftp.opts!.selectFiles, [0, 1]);
expect(tester.takeException(), isNull);
});
}
@@ -1795,9 +1908,10 @@ Future<AppPalette> _pumpCreateTaskForSubmit(
..categories = categories;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => config)
..bind(GopeedMethods.createTask, (task) {
..bind(GopeedMethods.createTaskBatch, (batch) {
final task = CreateTask(rid: batch.reqs!.single.rid, req: batch.reqs!.single.req, opts: batch.reqs!.single.opts);
onCreate(task);
return 'created-task';
return CreateTaskBatchResult(taskIds: ['created-task']);
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
final capabilities = AppCapabilities(LocalCapabilityInvoker(registry));
@@ -1850,9 +1964,10 @@ Future<AppPalette> _pumpCreateTaskWithCategories(
..bind(GopeedMethods.getConfig, (_) => config)
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
if (onCreate != null) {
registry.bind(GopeedMethods.createTask, (task) {
registry.bind(GopeedMethods.createTaskBatch, (batch) {
final task = CreateTask(rid: batch.reqs!.single.rid, req: batch.reqs!.single.req, opts: batch.reqs!.single.opts);
onCreate(task);
return 'created-task';
return CreateTaskBatchResult(taskIds: ['created-task']);
});
}
final capabilities = AppCapabilities(LocalCapabilityInvoker(registry));
@@ -0,0 +1,310 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/model/create_task_batch.dart';
import 'package:gopeed/api/model/options.dart';
import 'package:gopeed/api/model/request.dart';
import 'package:gopeed/api/model/resource.dart';
import 'package:gopeed/api/model/resolve_result.dart';
import 'package:gopeed/core/capabilities/app_capabilities.dart';
import 'package:gopeed/core/capabilities/capability_rpc.dart';
import 'package:gopeed/core/capabilities/gopeed_capability.dart';
import 'package:gopeed/features/tasks/application/pending_download.dart';
import 'package:gopeed/features/tasks/presentation/widgets/resolve_download_dialog.dart';
import 'package:gopeed/features/tasks/presentation/widgets/resolve_file_tree.dart';
import 'package:gopeed/shared/theme/app_theme.dart';
import 'package:gopeed/shared/widgets/app_loading_button.dart';
import 'package:gopeed/shared/widgets/app_tooltip.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
PendingDownload pending(String id) => PendingDownload(
id: id,
request: Request(url: 'https://example.com/$id'),
options: Options(),
);
ResolveResult resolved(String name) => ResolveResult(
id: name,
res: Resource(files: [FileInfo(name: name)]),
);
Future<void> open(
WidgetTester tester,
List<PendingDownload> sources,
CapabilityRegistry registry, {
ValueChanged<bool>? onClose,
double width = 900,
}) async {
tester.view.physicalSize = Size(width, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Builder(
builder: (context) => shad.PrimaryButton(
onPressed: () async {
final result = await showResolveDownloadsDialog(
context,
sources: sources,
service: GopeedService(LocalCapabilityInvoker(registry)),
);
onClose?.call(result);
},
child: const Text('Open'),
),
),
),
);
await tester.tap(find.text('Open'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
}
void main() {
for (final width in [390.0, 700.0, 1024.0]) {
testWidgets('file list fits and exposes ready selections at width $width', (tester) async {
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.resolve, (_) => resolved('file.txt'));
await open(tester, [pending('source')], registry, width: width);
await tester.pumpAndSettle();
expect(find.text('File List'), findsOneWidget);
expect(tester.widget<AppLoadingButton>(find.byKey(const ValueKey('resolve-create-button'))).onPressed, isNotNull);
expect(tester.takeException(), isNull);
});
}
testWidgets('shows all roots immediately and resolves sequentially while preserving choices', (tester) async {
final calls = <String>[];
final responses = [Completer<ResolveResult>(), Completer<ResolveResult>()];
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.resolve, (request) {
calls.add(request.req!.url);
return responses[calls.length - 1].future;
});
final sources = [pending('a'), pending('b')];
await open(tester, sources, registry);
expect(find.text('File List'), findsOneWidget);
expect(find.text('https://example.com/a'), findsOneWidget);
expect(find.text('https://example.com/b'), findsOneWidget);
expect(calls, ['https://example.com/a']);
expect(tester.widget<AppLoadingButton>(find.byKey(const ValueKey('resolve-create-button'))).onPressed, isNull);
expect(find.byWidgetPredicate((w) => w is AppTooltip && w.message == 'Waiting to resolve'), findsOneWidget);
responses.first.complete(resolved('first.txt'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(calls, ['https://example.com/a', 'https://example.com/b']);
expect(find.text('first.txt'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('resolve-tree-name-scroll-file:a:0')));
await tester.pump();
expect(sources.first.selected, isEmpty);
responses.last.complete(resolved('second.txt'));
await tester.pumpAndSettle();
expect(sources.first.selected, isEmpty);
expect(sources.last.selected, {0});
expect(tester.widget<AppLoadingButton>(find.byKey(const ValueKey('resolve-create-button'))).onPressed, isNotNull);
expect(tester.takeException(), isNull);
});
for (final filters in [
<String>['video'],
<String>['video', 'audio'],
]) {
testWidgets('new files clear type highlights without changing previous selections ($filters)', (tester) async {
final response = Completer<ResolveResult>();
final first = pending('first')
..result = ResolveResult(
res: Resource(
name: 'bundle',
files: [
FileInfo(name: 'video.mp4', size: 10),
FileInfo(name: 'song.mp3', size: 20),
FileInfo(name: 'picture.png', size: 30),
],
),
)
..selected.addAll([0, 1, 2]);
final second = pending('second');
final batches = <CreateTaskBatch>[];
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.resolve, (_) => response.future)
..bind(GopeedMethods.createTaskBatch, (batch) {
batches.add(batch);
return CreateTaskBatchResult(taskIds: ['created']);
});
await open(tester, [first, second], registry);
for (final filter in filters) {
await tester.tap(find.byKey(ValueKey('resolve-tree-filter-$filter')));
await tester.pump();
// Each selection is written back by the parent and rebuilds the tree.
expect(typeFilterActive(tester, filter), isTrue);
}
final expectedFirst = filters.length == 1 ? {0} : {0, 1};
expect(first.selected, expectedFirst);
expect(tester.widget<AppLoadingButton>(find.byKey(const ValueKey('resolve-create-button'))).onPressed, isNull);
final incoming = filters.length == 1 ? 'new-audio.mp3' : 'new-image.png';
response.complete(
ResolveResult(
id: 'incoming-rid',
res: Resource(files: [FileInfo(name: incoming, size: 40)]),
),
);
await tester.pumpAndSettle();
expect(first.selected, expectedFirst);
expect(second.selected, {0});
for (final filter in filters) {
expect(typeFilterActive(tester, filter), isFalse);
}
expect(find.text('${expectedFirst.length + 1} / 4 selected'), findsOneWidget);
final size = tester.widget<Text>(find.byKey(const ValueKey('resolve-tree-selected-size'))).data;
expect(size, '${filters.length == 1 ? 50 : 70} B');
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pumpAndSettle();
expect(batches, hasLength(2));
expect(batches.first.group, isTrue);
expect(
batches.first.reqs!.map((item) => item.opts!.name),
filters.length == 1 ? ['video.mp4'] : ['video.mp4', 'song.mp3'],
);
expect(batches.last.group, isFalse);
expect(batches.last.reqs!.single.rid, 'incoming-rid');
expect(batches.last.reqs!.single.opts!.selectFiles, [0]);
expect(tester.takeException(), isNull);
});
}
testWidgets('a failed source preserves type highlights and the successful source selection', (tester) async {
final response = Completer<ResolveResult>();
final first = pending('first')
..result = ResolveResult(
res: Resource(
files: [
FileInfo(name: 'video.mp4'),
FileInfo(name: 'song.mp3'),
],
),
)
..selected.addAll([0, 1]);
final second = pending('failed');
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.resolve, (_) => response.future);
await open(tester, [first, second], registry);
await tester.tap(find.byKey(const ValueKey('resolve-tree-filter-video')));
await tester.pump();
expect(typeFilterActive(tester, 'video'), isTrue);
response.completeError(StateError('unavailable'));
await tester.pumpAndSettle();
expect(first.selected, {0});
expect(second.selected, isEmpty);
expect(typeFilterActive(tester, 'video'), isTrue);
expect(find.text('1 / 2 selected'), findsOneWidget);
expect(tester.widget<AppLoadingButton>(find.byKey(const ValueKey('resolve-create-button'))).onPressed, isNotNull);
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
testWidgets('same-name sources synchronize individual, root and all-file selections', (tester) async {
final sources = [
for (final id in ['first', 'second'])
pending(id)
..result = ResolveResult(
res: Resource(
name: 'bundle',
files: [
FileInfo(name: 'a.txt'),
FileInfo(name: 'b.txt'),
],
),
)
..selected.addAll([0, 1]),
];
await open(tester, sources, CapabilityRegistry(createAppCapabilityCodecs()));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('resolve-tree-name-scroll-file:first:0')));
await tester.pumpAndSettle();
expect(sources.first.selected, {1});
expect(sources.last.selected, {0, 1});
await tester.tap(find.byKey(const ValueKey('resolve-tree-name-scroll-folder:second')));
await tester.pumpAndSettle();
expect(sources.first.selected, {1});
expect(sources.last.selected, isEmpty);
await tester.tap(find.byKey(const ValueKey('resolve-tree-name-scroll-file:first:1')));
await tester.pumpAndSettle();
expect(sources.first.selected, isEmpty);
expect(tester.widget<AppLoadingButton>(find.byKey(const ValueKey('resolve-create-button'))).onPressed, isNull);
final selectAll = find.descendant(of: find.byType(ResolveFileTree), matching: find.byType(shad.Checkbox)).first;
await tester.tap(selectAll);
await tester.pumpAndSettle();
expect(sources.every((source) => source.selected.length == 2 && source.selected.containsAll([0, 1])), isTrue);
await tester.tap(selectAll);
await tester.pumpAndSettle();
expect(sources.every((source) => source.selected.isEmpty), isTrue);
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
testWidgets('cancel stops the queue and ignores the late response', (tester) async {
final response = Completer<ResolveResult>();
var calls = 0;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.resolve, (_) {
calls++;
return response.future;
});
bool? closed;
await open(tester, [pending('a'), pending('b')], registry, onClose: (value) => closed = value);
await tester.tap(find.byKey(const ValueKey('resolve-close-button')));
await tester.pumpAndSettle();
response.complete(resolved('late.txt'));
await tester.pumpAndSettle();
expect(calls, 1);
expect(closed, isFalse);
expect(find.text('late.txt'), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('failed root is disabled and only ready selections enter a single batch', (tester) async {
final batches = <CreateTaskBatch>[];
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.resolve, (request) {
if (request.req!.url.endsWith('/bad')) throw StateError('unavailable');
return resolved('ready.txt');
})
..bind(GopeedMethods.createTaskBatch, (batch) {
batches.add(batch);
return CreateTaskBatchResult(taskIds: ['created']);
});
bool? closed;
await open(tester, [pending('bad'), pending('good')], registry, onClose: (value) => closed = value);
await tester.pumpAndSettle();
expect(find.textContaining('unavailable'), findsNothing);
expect(find.byWidgetPredicate((w) => w is AppTooltip && w.message.contains('unavailable')), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pumpAndSettle();
expect(batches.single.reqs!.single.rid, 'ready.txt');
expect(closed, isTrue);
expect(tester.takeException(), isNull);
});
}
bool typeFilterActive(WidgetTester tester, String filter) {
final decoration =
tester
.widget<DecoratedBox>(
find
.descendant(
of: find.byKey(ValueKey('resolve-tree-filter-$filter')),
matching: find.byType(DecoratedBox),
)
.first,
)
.decoration
as BoxDecoration;
return decoration.color != null;
}
@@ -0,0 +1,467 @@
import 'package:flutter/widgets.dart';
import 'package:gopeed/core/icons/gopeed_icons.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/features/tasks/presentation/widgets/resolve_file_tree.dart';
import 'package:gopeed/shared/theme/app_theme.dart';
import 'package:gopeed/shared/widgets/file_tree_view.dart';
import 'package:gopeed/shared/widgets/virtual_tree_view.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
import 'package:gopeed/api/model/options.dart';
import 'package:gopeed/api/model/request.dart';
import 'package:gopeed/api/model/resource.dart';
import 'package:gopeed/api/model/resolve_result.dart';
import 'package:gopeed/features/tasks/application/pending_download.dart';
PendingDownload source(String id) =>
PendingDownload(
id: id,
request: Request(url: 'https://example.com/$id'),
options: Options(),
)
..result = ResolveResult(
res: Resource(
name: 'bundle',
files: [
FileInfo(name: '0.txt'),
FileInfo(name: '1.txt'),
],
),
)
..selected.addAll([0, 1]);
void main() {
for (final width in [390.0, 700.0, 1024.0]) {
testWidgets('live mixed sources keep input order, selection and expansion at $width', (tester) async {
tester.view.physicalSize = Size(width, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final sources = [
for (final id in ['http', 'group', 'ungrouped', 'bt', 'failed'])
PendingDownload(
id: id,
request: Request(url: 'https://example.com/$id'),
options: Options(),
),
];
final http = sources[0];
final group = sources[1];
final ungrouped = sources[2];
final bt = sources[3];
bt.request.url = 'magnet:?xt=urn:btih:infohash';
Future<void> show() async {
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: ResolveFileTree(
sources: sources,
onSelectionChanged: (keys) {
for (final pending in sources) {
pending.selected
..clear()
..addAll(keys.where((key) => key.$1 == pending.id).map((key) => key.$2));
}
},
),
),
);
await tester.pump();
}
List<shad.TreeItemNode<FileTreeNode<PendingFileKey>>> roots() => tester
.widget<VirtualTreeView<FileTreeNode<PendingFileKey>>>(
find.byType(VirtualTreeView<FileTreeNode<PendingFileKey>>),
)
.nodes
.cast<shad.TreeItemNode<FileTreeNode<PendingFileKey>>>();
void expectOrder(List<String> labels) => expect(roots().map((node) => node.data.label), labels);
await show();
expectOrder(sources.map((pending) => pending.request.url).toList());
http.result = ResolveResult(
id: 'http-rid',
res: Resource(files: [FileInfo(name: 'z-http.txt')]),
);
http.selected.add(0);
await show();
expectOrder(['z-http.txt', ...sources.skip(1).map((pending) => pending.request.url)]);
await tester.tap(find.byKey(const ValueKey('resolve-tree-name-scroll-file:http:0')));
await tester.pump();
expect(http.selected, isEmpty);
group.result = ResolveResult(
res: Resource(
name: 'bundle',
files: [
FileInfo(name: 'one.txt'),
FileInfo(name: 'two.txt'),
],
),
);
group.selected.addAll([0, 1]);
await show();
expectOrder(['z-http.txt', 'bundle', ...sources.skip(2).map((pending) => pending.request.url)]);
ungrouped.result = ResolveResult(
res: Resource(
files: [
FileInfo(name: 'z-first.txt'),
FileInfo(name: 'nested.txt', path: 'directory'),
FileInfo(name: 'a-last.txt'),
],
),
);
ungrouped.selected.addAll([0, 1, 2]);
await tester.tap(find.byKey(const ValueKey('resolve-tree-node-expand-folder:group')));
await tester.pump(const Duration(milliseconds: 50));
await show();
await tester.pump(const Duration(milliseconds: 250));
expect(roots()[1].expanded, isFalse);
expectOrder([
'z-http.txt',
'bundle',
'z-first.txt',
'directory',
'a-last.txt',
bt.request.url,
sources.last.request.url,
]);
bt.result = ResolveResult(
id: 'bt-rid',
res: Resource(
name: 'torrent',
files: [
FileInfo(name: 'bt-one'),
FileInfo(name: 'bt-two'),
],
),
);
bt.selected.addAll([0, 1]);
await show();
sources.last.resolveError = 'cannot resolve';
await show();
expectOrder([
'z-http.txt',
'bundle',
'z-first.txt',
'directory',
'a-last.txt',
'torrent',
sources.last.request.url,
]);
expect(roots()[1].expanded, isFalse);
expect(http.selected, isEmpty);
expect(group.selected, {0, 1});
expect(ungrouped.selected, {0, 1, 2});
expect(bt.selected, {0, 1});
expect(sources.last.selected, isEmpty);
expect(tester.takeException(), isNull);
});
}
testWidgets('ED2K uses a file icon and filename while BT uses its resource name and folder icon', (tester) async {
final ed2k =
PendingDownload(
id: 'ed2k',
request: Request(url: 'ed2k://|file|payload.bin|4|0123456789ABCDEF0123456789ABCDEF|/'),
options: Options(),
)
..result = ResolveResult(
id: 'ed2k-rid',
res: Resource(
hash: '0123456789ABCDEF0123456789ABCDEF',
files: [FileInfo(name: 'payload.bin')],
),
)
..selected.add(0);
final bt =
PendingDownload(
id: 'bt',
request: Request(url: 'magnet:?xt=urn:btih:infohash'),
options: Options(),
)
..result = ResolveResult(
id: 'bt-rid',
res: Resource(
name: 'torrent resource',
hash: 'infohash',
files: [FileInfo(name: 'torrent-file.bin')],
),
)
..selected.add(0);
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: ResolveFileTree(sources: [ed2k, bt], onSelectionChanged: (_) {}),
),
);
await tester.pumpAndSettle();
expect(find.text('payload.bin'), findsOneWidget);
expect(find.text('torrent resource'), findsOneWidget);
expect(find.byIcon(GopeedIcons.folderBt), findsOneWidget);
expect(find.text(ed2k.request.url), findsNothing);
expect(tester.takeException(), isNull);
});
for (final directory in [false, true]) {
testWidgets('FTP resource ${directory ? 'directory' : 'file'} keeps its actual structure', (tester) async {
final pending = source('ftp');
pending.request.url = directory ? 'ftp://files.example/folder' : 'ftpes://files.example/file.torrent';
pending.result = ResolveResult(
id: 'ftp-rid',
res: Resource(
name: directory ? 'folder' : '',
files: [FileInfo(name: 'file.torrent')],
),
);
pending.selected
..clear()
..add(0);
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: ResolveFileTree(sources: [pending], onSelectionChanged: (_) {}),
),
);
await tester.pumpAndSettle();
expect(pending.isBt, isFalse);
expect(pending.isGroup, isFalse);
expect(pending.name, directory ? 'folder' : 'file.torrent');
expect(find.text('folder'), directory ? findsOneWidget : findsNothing);
expect(find.byIcon(shad.BootstrapIcons.folder2), directory ? findsOneWidget : findsNothing);
expect(find.byIcon(GopeedIcons.folderBt), findsNothing);
expect(find.text('file.torrent'), findsOneWidget);
expect(planDownloads([pending]).single.request.reqs!.single.rid, 'ftp-rid');
expect(tester.takeException(), isNull);
});
}
for (final extension in [true, false]) {
testWidgets('magnet root icon follows the resolved resource (extension=$extension)', (tester) async {
final pending = source('magnet');
pending.request.url = 'magnet:?xt=urn:btih:infohash';
pending.result!.id = extension ? '' : 'bt-rid';
for (final file in pending.files) {
if (extension) file.req = Request(url: 'https://example.com/${file.name}');
}
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: ResolveFileTree(sources: [pending], onSelectionChanged: (_) {}),
),
);
await tester.pumpAndSettle();
expect(pending.isGroup, extension);
expect(find.byIcon(GopeedIcons.folderBt), extension ? findsNothing : findsOneWidget);
expect(find.byIcon(GopeedIcons.file), findsNWidgets(2));
expect(find.byIcon(shad.BootstrapIcons.folder2), extension ? findsOneWidget : findsNothing);
expect(tester.takeException(), isNull);
});
}
testWidgets('ordinary single files replace URL placeholders without a source directory', (tester) async {
final pending = PendingDownload(
id: 'http',
request: Request(url: 'https://example.com/file'),
options: Options(),
);
Future<void> show() async {
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: ResolveFileTree(sources: [pending], onSelectionChanged: (_) {}),
),
);
await tester.pump();
}
await show();
expect(find.text(pending.request.url), findsOneWidget);
pending.result = ResolveResult(
id: 'http-rid',
res: Resource(
name: '',
files: [FileInfo(name: 'file.txt')],
),
);
pending.selected.add(0);
await show();
expect(find.text('file.txt'), findsOneWidget);
expect(find.text(pending.request.url), findsNothing);
final tree = tester.widget<VirtualTreeView<FileTreeNode<PendingFileKey>>>(
find.byType(VirtualTreeView<FileTreeNode<PendingFileKey>>),
);
expect(tree.nodes, hasLength(1));
expect(tree.nodes.single.children, isEmpty);
expect((tree.nodes.single as shad.TreeItemNode<FileTreeNode<PendingFileKey>>).data.item!.data, ('http', 0));
expect(tester.takeException(), isNull);
});
for (final filePath in ['', 'disc1']) {
testWidgets('named single-file extension keeps its resource directory (path=$filePath)', (tester) async {
final pending =
PendingDownload(
id: 'extension',
request: Request(url: 'https://example.com/track.mp3'),
options: Options(path: '/downloads'),
)
..result = ResolveResult(
res: Resource(
name: 'album',
files: [
FileInfo(
name: 'track.mp3',
path: filePath,
req: Request(url: 'https://cdn.example.com/track.mp3'),
),
],
),
)
..selected.add(0);
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: ResolveFileTree(sources: [pending], onSelectionChanged: (_) {}),
),
);
await tester.pumpAndSettle();
final tree = tester.widget<VirtualTreeView<FileTreeNode<PendingFileKey>>>(
find.byType(VirtualTreeView<FileTreeNode<PendingFileKey>>),
);
final roots = tree.nodes.cast<shad.TreeItemNode<FileTreeNode<PendingFileKey>>>();
expect(roots.map((node) => node.data.label), ['album']);
expect(roots.single.expanded, isTrue);
final child = roots.single.children.single as shad.TreeItemNode<FileTreeNode<PendingFileKey>>;
expect(child.data.label, filePath.isEmpty ? 'track.mp3' : 'disc1');
final plan = planDownloads([pending]).single.request;
expect(plan.group, isFalse);
expect(plan.reqs!.single.opts!.path, filePath.isEmpty ? '/downloads/album' : '/downloads/album/disc1');
expect(tester.takeException(), isNull);
});
}
testWidgets('a live group replaces its URL placeholder with an expanded group directory', (tester) async {
final pending = PendingDownload(
id: 'group',
request: Request(url: 'https://example.com/group'),
options: Options(),
);
Future<void> show() async {
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: ResolveFileTree(sources: [pending], onSelectionChanged: (_) {}),
),
);
await tester.pump();
}
await show();
pending.result = ResolveResult(
res: Resource(
name: 'bundle',
files: [
FileInfo(name: 'one.txt'),
FileInfo(name: 'two.txt'),
],
),
);
pending.selected.addAll([0, 1]);
await show();
expect(find.text('bundle'), findsOneWidget);
expect(find.text('one.txt'), findsOneWidget);
expect(find.text('two.txt'), findsOneWidget);
expect(find.byIcon(shad.BootstrapIcons.folder2), findsOneWidget);
expect(tester.takeException(), isNull);
});
for (final width in [390.0, 700.0, 1024.0]) {
testWidgets('ungrouped roots preserve relative directories and source selection at $width', (tester) async {
tester.view.physicalSize = Size(width, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final sources = [source('first'), source('second')];
for (final pending in sources) {
pending.result!.res.name = '';
pending.files[0].name = 'same.txt';
pending.files[1].name = 'video.mp4';
pending.files[1].path = 'nested';
}
Set<PendingFileKey> selected = {};
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: ResolveFileTree(sources: sources, onSelectionChanged: (keys) => selected = keys),
),
);
await tester.pumpAndSettle();
final tree = tester.widget<VirtualTreeView<FileTreeNode<PendingFileKey>>>(
find.byType(VirtualTreeView<FileTreeNode<PendingFileKey>>),
);
expect(tree.nodes, hasLength(4));
expect(tree.nodes.cast<shad.TreeItemNode<FileTreeNode<PendingFileKey>>>().map((node) => node.data.label), [
'same.txt',
'nested',
'same.txt',
'nested',
]);
expect(
tree.nodes.cast<shad.TreeItemNode<FileTreeNode<PendingFileKey>>>().map((node) => node.data.key).toSet(),
hasLength(4),
);
expect(find.text('nested'), findsNWidgets(2));
expect(find.text('same.txt'), findsNWidgets(2));
expect(find.text(sources.first.request.url), findsNothing);
await tester.tap(find.byKey(const ValueKey('resolve-tree-name-scroll-file:first:0')));
await tester.pump();
expect(selected, {('first', 1), ('second', 0), ('second', 1)});
final secondDirectoryKey = (tree.nodes.last as shad.TreeItemNode<FileTreeNode<PendingFileKey>>).data.key;
await tester.tap(find.byKey(ValueKey('resolve-tree-name-scroll-$secondDirectoryKey')));
await tester.pump();
expect(selected, {('first', 1), ('second', 0)});
await tester.tap(find.byKey(const ValueKey('resolve-tree-filter-video')));
await tester.pump();
expect(selected, {('first', 1), ('second', 1)});
expect(tester.takeException(), isNull);
});
}
for (final width in [390.0, 700.0, 1024.0]) {
testWidgets('same-name roots isolate file indexes and root selection at $width', (tester) async {
tester.view.physicalSize = Size(width, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final first = source('first');
final second = source('second');
Set<PendingFileKey> selected = {('first', 0), ('first', 1), ('second', 0), ('second', 1)};
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: ResolveFileTree(sources: [first, second], onSelectionChanged: (keys) => selected = keys),
),
);
await tester.pumpAndSettle();
expect(find.text('bundle'), findsNWidgets(2));
await tester.tap(find.byKey(const ValueKey('resolve-tree-name-scroll-file:first:0')));
await tester.pump();
expect(selected, {('first', 1), ('second', 0), ('second', 1)});
await tester.tap(find.byKey(const ValueKey('resolve-tree-name-scroll-folder:second')));
await tester.pump();
expect(selected, {('first', 1)});
expect(tester.takeException(), isNull);
});
}
}
@@ -0,0 +1,45 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/features/tasks/presentation/widgets/task_delete_dialog.dart';
import 'package:gopeed/shared/theme/app_theme.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
void main() {
for (final width in [390.0, 700.0, 1024.0]) {
testWidgets('group deletion confirms affected downloads and files at width $width', (tester) async {
tester.view.physicalSize = Size(width, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
bool? keepFiles;
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Builder(
builder: (context) => shad.PrimaryButton(
onPressed: () async {
keepFiles = await showTaskDeleteDialog(
context,
taskCount: 1,
keepFiles: true,
memberCount: 65,
fileCount: 65,
);
},
child: const Text('Open delete dialog'),
),
),
),
);
await tester.tap(find.text('Open delete dialog'));
await tester.pumpAndSettle();
expect(find.text('Affects 65 downloads and 65 files.'), findsOneWidget);
expect(keepFiles, isNull);
await tester.tap(find.text('Delete'));
await tester.pumpAndSettle();
expect(keepFiles, isTrue);
expect(tester.takeException(), isNull);
});
}
}
@@ -0,0 +1,163 @@
import 'package:flutter/widgets.dart';
import 'package:flutter/foundation.dart' show TargetPlatform, debugDefaultTargetPlatformOverride;
import 'package:gopeed/features/home/presentation/pages/home_page.dart';
import 'package:gopeed/features/tasks/presentation/widgets/task_file_manager.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gopeed/api/model/task.dart' as api;
import 'package:gopeed/features/tasks/domain/task_record.dart';
import 'package:gopeed/features/tasks/application/tasks_controller.dart';
import 'package:gopeed/features/tasks/presentation/widgets/task_file_browser_dialog.dart';
import 'package:gopeed/features/tasks/presentation/widgets/task_card/task_card.dart';
import 'package:gopeed/shared/theme/app_theme.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
import 'package:flutter/material.dart' show Icons;
api.Task raw(
String id, {
String parent = '',
String status = 'running',
String directory = '/downloads/folder',
String name = 'file.txt',
}) => api.Task.fromJson({
'id': id,
'name': name,
if (parent.isNotEmpty)
'group': {
'id': parent,
'name': 'folder',
'path': directory.contains('\\') ? r'C:\Downloads\folder' : '/downloads/folder',
'createdAt': '2026-01-01T00:00:00Z',
},
'parentId': parent,
'protocol': 'http',
'status': status,
'uploading': false,
'createdAt': '2026-01-01T00:00:00Z',
'updatedAt': '2026-01-01T00:00:00Z',
'meta': {
'req': {'url': 'https://example.com/$id'},
'opts': {'path': directory, 'name': name, 'size': 100},
'res': {
'size': 100,
'files': [
{'name': name, 'size': 100},
],
},
},
'progress': {'used': 0, 'speed': 10, 'downloaded': 25, 'uploaded': 0, 'uploadSpeed': 0},
});
TaskRecord grouped({String name = 'first.txt'}) =>
taskRecordsFromApi([raw('child', parent: 'group', name: name)]).single;
class UpdatingTasks extends TasksController {
@override
Future<TasksState> build() async => TasksState(tasks: [grouped()]);
void rename() => state = AsyncData(TasksState(tasks: [grouped(name: 'first (1).txt')]));
}
void main() {
for (final platform in [TargetPlatform.android, TargetPlatform.iOS]) {
testWidgets('tablet group expands its member tasks on $platform', (tester) async {
debugDefaultTargetPlatformOverride = platform;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
tester.view.physicalSize = const Size(1024, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
ProviderScope(
overrides: [tasksControllerProvider.overrideWith(UpdatingTasks.new)],
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: const HomePage(),
),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
await tester.tap(find.text('folder').first);
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
expect(find.byType(TaskFileManagerView), findsNothing);
expect(find.byType(TaskCard), findsNWidgets(2));
expect(find.text('first.txt'), findsOneWidget);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox());
debugDefaultTargetPlatformOverride = null;
});
}
test('Windows backend root and child path are rebased on a POSIX client', () {
final record = taskRecordsFromApi([raw('child', parent: 'group', directory: r'C:\Downloads\folder\nested')]).single;
expect(record.files.single.path, 'nested');
expect(record.files.single.name, 'file.txt');
});
testWidgets('open group file browser updates renamed child from task polling', (tester) async {
tester.view.physicalSize = const Size(1100, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
late UpdatingTasks controller;
await tester.pumpWidget(
ProviderScope(
overrides: [tasksControllerProvider.overrideWith(() => controller = UpdatingTasks())],
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Consumer(
builder: (context, ref, _) {
final tasks = ref.watch(tasksControllerProvider).value?.tasks;
return shad.PrimaryButton(
onPressed: tasks == null ? null : () => showTaskFileBrowserDialog(context, tasks.single),
child: const Text('Open'),
);
},
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
expect(find.text('first.txt'), findsOneWidget);
controller.rename();
await tester.pumpAndSettle();
expect(find.text('first (1).txt'), findsOneWidget);
});
for (final width in [390.0, 700.0, 1024.0]) {
testWidgets('a running group with paused members offers resume on its card at $width', (tester) async {
tester.view.physicalSize = Size(width, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final record = taskRecordsFromApi([
raw('running', parent: 'group'),
raw('paused', parent: 'group', status: 'pause'),
]).single;
expect(record.canResume, isTrue);
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Center(
child: TaskCard(
task: record,
selected: false,
batchMode: false,
selectedInBatch: false,
onPressed: () {},
onToggleBatch: () {},
onPause: () {},
onResume: () {},
onDelete: () {},
),
),
),
);
await tester.pump();
expect(find.byIcon(Icons.play_arrow), findsOneWidget);
await tester.pumpWidget(const SizedBox());
});
}
}
@@ -0,0 +1,135 @@
import 'package:path/path.dart' as path;
import 'package:flutter/material.dart' show Icons;
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/api.dart' as api_client;
import 'package:gopeed/api/model/task.dart' as api;
import 'package:gopeed/core/network/gopeed/gopeed_transport.dart';
import 'package:gopeed/features/tasks/domain/task_record.dart';
import 'package:gopeed/features/tasks/presentation/widgets/task_file_manager.dart';
import 'package:gopeed/shared/theme/app_theme.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
import 'package:flutter/services.dart';
class _Transport implements GopeedTransport {
@override
String join(String path) => 'https://server.example$path';
@override
Future<dynamic> request(
String path, {
String method = 'GET',
dynamic data,
Map<String, dynamic>? queryParameters,
}) async => null;
}
api.Task fixture(String id, {required String directory, String name = 'episode #2?.txt', bool folder = false}) =>
api.Task.fromJson({
'id': id,
'name': folder ? 'nested-torrent' : name,
'group': {'id': 'group', 'name': 'folder', 'path': '/downloads/folder', 'createdAt': '2026-01-01T00:00:00Z'},
'parentId': 'group',
'protocol': 'http',
'status': 'done',
'uploading': false,
'createdAt': '2026-01-01T00:00:00Z',
'updatedAt': '2026-01-01T00:00:00Z',
'meta': {
'req': {'url': 'https://example.com/file'},
'opts': {'path': directory, 'name': folder ? 'nested-torrent' : name, 'size': 100},
'res': {
'name': folder ? 'nested-torrent' : '',
'size': 100,
'files': [
{'name': name, 'path': folder ? 'subdir' : '', 'size': 100},
],
},
},
'progress': {'used': 0, 'speed': 0, 'downloaded': 100, 'uploadSpeed': 0, 'uploaded': 0},
});
void main() {
for (final folder in [false, true]) {
testWidgets('local group actions use member path folder=$folder', (tester) async {
tester.view.physicalSize = const Size(900, 700);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
const channel = MethodChannel('dev.fluttercommunity.plus/share');
List<dynamic>? shared;
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, (call) async {
shared = (call.arguments as Map)['paths'] as List<dynamic>;
return 'dev.fluttercommunity.plus/share/unavailable';
});
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, null));
final memberRoot = path.style == path.Style.windows ? r'D:\Other' : '/elsewhere';
final group = taskRecordsFromApi([fixture('child', directory: memberRoot, folder: folder)]).single;
final expected = folder
? path.join(memberRoot, 'nested-torrent', 'subdir', 'episode #2?.txt')
: path.join(memberRoot, 'episode #2?.txt');
expect(group.files.single.localPath, expected);
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: TaskFileManagerView(task: group, webActions: false, desktopActions: false),
),
);
await tester.pumpAndSettle();
var current = '';
for (final segment in group.files.single.path.split('/').where((s) => s.isNotEmpty)) {
current += '/$segment';
await tester.tap(find.byKey(ValueKey('managed-file-$current')));
await tester.pumpAndSettle();
}
await tester.tap(find.byIcon(Icons.share_outlined));
await tester.pumpAndSettle();
expect(shared, [expected]);
expect(tester.takeException(), isNull);
});
}
for (final windows in [false, true]) {
for (final legacy in [false, true]) {
for (final folder in [false, true]) {
testWidgets('member URL windows=$windows legacy=$legacy folder=$folder', (tester) async {
tester.view.physicalSize = const Size(900, 700);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
api_client.setTransportForTesting(_Transport());
String? launched;
const channel = MethodChannel('plugins.flutter.io/url_launcher');
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, (call) async {
if (call.method == 'launch') {
launched = (call.arguments as Map)['url'] as String;
return true;
}
return null;
});
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, null));
final dir = windows ? r'C:\Downloads\folder\nested' : '/downloads/folder/nested';
final group = taskRecordsFromApi([fixture('child #1', directory: dir, folder: folder)]).single;
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: TaskFileManagerView(task: group, webActions: true, desktopActions: false),
),
);
await tester.pumpAndSettle();
var current = '';
for (final segment in group.files.single.path.split('/').where((s) => s.isNotEmpty)) {
current += '/$segment';
await tester.tap(find.byKey(ValueKey('managed-file-$current')));
await tester.pumpAndSettle();
}
await tester.tap(find.byIcon(Icons.open_in_new));
await tester.pumpAndSettle();
final expected =
'https://server.example/fs/tasks/child%20%231/${folder ? 'subdir/' : ''}episode%20%232%3F.txt';
expect(launched, expected);
expect(tester.takeException(), isNull);
});
}
}
}
}
@@ -8,6 +8,72 @@ import 'package:gopeed/shared/theme/app_theme.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
void main() {
for (final width in [390.0, 700.0, 1024.0]) {
testWidgets('group tree shows member progress and status at width $width', (tester) async {
tester.view.physicalSize = Size(width, 600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final task = TaskRecord(
id: 'group',
name: 'folder',
status: TaskStatus.downloading,
downloaded: '150 B',
total: '200 B',
progress: 0.75,
url: '',
storagePath: '/downloads/folder',
isGroup: true,
isFolder: true,
uploading: false,
files: const [
TaskFileNode(
path: '',
name: 'running.txt',
sizeBytes: 100,
downloadedBytes: 50,
resourceIndex: 0,
status: TaskStatus.downloading,
),
TaskFileNode(
path: '',
name: 'completed.txt',
sizeBytes: 100,
downloadedBytes: 100,
resourceIndex: 1,
status: TaskStatus.completed,
),
],
);
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Padding(
padding: const EdgeInsets.all(16),
child: TaskFileTree(task: task),
),
),
);
await tester.pump();
expect(tester.widget<TaskProgressBar>(find.byKey(const ValueKey('task-files-total-progress'))).value, 0.75);
final running = find.descendant(
of: find.byKey(const ValueKey('task-file-progress-file:0')),
matching: find.byType(shad.CircularProgressIndicator),
);
expect(tester.widget<shad.CircularProgressIndicator>(running).value, 0.5);
expect(
find.descendant(
of: find.byKey(const ValueKey('task-file-progress-file:1')),
matching: find.byIcon(shad.BootstrapIcons.checkCircleFill),
),
findsOneWidget,
);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox());
});
}
for (final scenario in [
(protocol: api.Protocol.http, completed: false),
(protocol: api.Protocol.http, completed: true),
@@ -2,6 +2,7 @@ import 'dart:io';
import 'package:flutter/material.dart' show MaterialApp, Tooltip;
import 'package:flutter/widgets.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/shared/widgets/app_tooltip.dart';
@@ -16,6 +17,35 @@ void main() {
expect(tester.widget<Tooltip>(find.byType(Tooltip)).message, 'Details');
});
testWidgets('focusable status tooltip exposes its message on keyboard focus', (tester) async {
await tester.pumpWidget(
const MaterialApp(
home: AppTooltip(message: 'Waiting for resolution', focusable: true, child: SizedBox.square(dimension: 24)),
),
);
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pumpAndSettle();
expect(find.text('Waiting for resolution'), findsOneWidget);
FocusManager.instance.primaryFocus?.unfocus();
await tester.pumpAndSettle();
expect(find.text('Waiting for resolution'), findsNothing);
});
testWidgets('status tooltip can be read with a touch long press', (tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Center(
child: AppTooltip(message: 'Resolution failed', focusable: true, child: SizedBox.square(dimension: 24)),
),
),
);
await tester.longPress(find.byType(AppTooltip));
await tester.pump();
expect(find.text('Resolution failed'), findsOneWidget);
Tooltip.dismissAllToolTips();
await tester.pumpAndSettle();
});
test('product source does not instantiate Tooltip directly', () {
final violations = <String>[];
final tooltipConstructor = RegExp(r'\b(?:\w+\.)?Tooltip\s*\(');
@@ -0,0 +1,161 @@
import 'package:flutter/widgets.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/shared/theme/app_theme.dart';
import 'package:gopeed/shared/widgets/file_tree_view.dart';
import 'package:gopeed/shared/widgets/virtual_tree_view.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
void main() {
testWidgets('clearing an explicit sort restores mixed file and directory input order', (tester) async {
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: FileTreeView<int>(
items: const [
FileTreeItem(key: 'z', path: '', name: 'z.txt', size: 3, data: 0),
FileTreeItem(key: 'nested', path: 'folder', name: 'child.txt', size: 2, data: 1),
FileTreeItem(key: 'a', path: '', name: 'a.txt', size: 1, data: 2),
],
),
),
);
await tester.pumpAndSettle();
List<String> labels() => tester
.widget<VirtualTreeView<FileTreeNode<int>>>(find.byType(VirtualTreeView<FileTreeNode<int>>))
.nodes
.cast<shad.TreeItemNode<FileTreeNode<int>>>()
.map((node) => node.data.label)
.toList();
expect(labels(), ['z.txt', 'folder', 'a.txt']);
final sort = find.byKey(const ValueKey('file-tree-sort-name-asc'));
await tester.tap(sort);
await tester.pump();
expect(labels(), ['folder', 'a.txt', 'z.txt']);
await tester.tap(sort);
await tester.pump();
expect(labels(), ['folder', 'z.txt', 'a.txt']);
await tester.tap(sort);
await tester.pump();
expect(labels(), ['z.txt', 'folder', 'a.txt']);
});
for (final width in [390.0, 700.0, 1024.0]) {
testWidgets('long directory display labels remain readable at $width', (tester) async {
tester.view.physicalSize = Size(width, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final name = List.filled(12, 'long-resource-title').join('-');
final nestedName = List.filled(14, 'nested-directory-title').join('-');
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: FileTreeView<int>(
items: const [FileTreeItem(key: 'child', path: 'source-0/nested', name: 'a.txt', size: 10, data: 0)],
directoryLabels: {'source-0': name, 'source-0/nested': nestedName},
),
),
);
await tester.pumpAndSettle();
for (final label in [name, nestedName]) {
final paragraph = tester.renderObject<RenderParagraph>(find.text(label));
expect(paragraph.size.width, greaterThanOrEqualTo(paragraph.getMaxIntrinsicWidth(double.infinity)));
}
expect(find.byKey(const ValueKey('file-tree-horizontal-scrollbar')), findsOneWidget);
final root = find.byKey(const ValueKey('file-tree-name-scroll-folder:source-0'));
final before = tester.getTopLeft(find.text(name));
final trailing = tester.getRect(find.byKey(const ValueKey('file-tree-trailing-folder:source-0')));
await tester.drag(root, const Offset(-120, 0));
await tester.pumpAndSettle();
expect(tester.getTopLeft(find.text(name)).dx, lessThan(before.dx));
expect(tester.getRect(find.byKey(const ValueKey('file-tree-trailing-folder:source-0'))), trailing);
expect(tester.takeException(), isNull);
});
}
testWidgets('empty directory root keeps its identity and expansion when files arrive', (tester) async {
late StateSetter update;
var items = [
const FileTreeItem(key: 'root', path: 'root', name: 'pending', size: 0, data: -1, directoryOnly: true),
];
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: StatefulBuilder(
builder: (context, setState) {
update = setState;
return FileTreeView<int>(
items: items,
directoryLabels: const {'root': 'Source'},
initialExpandedDepth: 1,
iconWidgetBuilder: (_, node) => node.isFolder ? const Text('status') : const Text('file'),
);
},
),
),
);
await tester.pumpAndSettle();
expect(find.text('Source'), findsOneWidget);
expect(find.text('pending'), findsNothing);
expect(find.text('status'), findsOneWidget);
update(() => items = [const FileTreeItem(key: 'child', path: 'root', name: 'payload.txt', size: 12, data: 0)]);
await tester.pumpAndSettle();
expect(find.text('Source'), findsOneWidget);
expect(find.text('payload.txt'), findsOneWidget);
expect(find.text('file'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('file-tree-node-expand-folder:root')));
await tester.pumpAndSettle();
expect(find.text('payload.txt'), findsNothing);
expect(find.text('Source'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('live tree updates preserve a collapse requested during its animation', (tester) async {
late StateSetter update;
var items = const [
FileTreeItem(key: 'first', path: 'bundle', name: 'first.txt', size: 1, data: 0),
FileTreeItem(key: 'second', path: 'bundle', name: 'second.txt', size: 1, data: 1),
FileTreeItem(key: 'pending', path: 'source-b', name: 'source B', size: 0, data: 2, directoryOnly: true),
];
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: StatefulBuilder(
builder: (context, setState) {
update = setState;
return FileTreeView<int>(
items: items,
directoryLabels: const {'bundle': 'bundle', 'source-b': 'source B'},
initialExpandedDepth: 1,
);
},
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('file-tree-node-expand-folder:bundle')));
await tester.pump(const Duration(milliseconds: 50));
update(() {
items = const [
FileTreeItem(key: 'first', path: 'bundle', name: 'first.txt', size: 1, data: 0),
FileTreeItem(key: 'second', path: 'bundle', name: 'second.txt', size: 1, data: 1),
FileTreeItem(key: 'arrived', path: '', name: 'later.txt', size: 1, data: 3),
];
});
await tester.pump();
await tester.pump(const Duration(milliseconds: 250));
final tree = tester.widget<VirtualTreeView<FileTreeNode<int>>>(find.byType(VirtualTreeView<FileTreeNode<int>>));
final bundle = tree.nodes.cast<shad.TreeItemNode<FileTreeNode<int>>>().first;
expect(bundle.data.label, 'bundle');
expect(bundle.expanded, isFalse);
expect(find.text('later.txt'), findsOneWidget);
expect(tester.takeException(), isNull);
});
}
+45 -18
View File
@@ -1,3 +1,7 @@
import 'package:gopeed/features/tasks/application/pending_download.dart';
import 'package:gopeed/api/model/options.dart' as preview_options;
import 'package:gopeed/api/model/request.dart' as preview_request;
import 'package:gopeed/api/model/resolve_result.dart' as preview_resolve;
import 'dart:async';
import 'dart:io';
@@ -30,6 +34,7 @@ import 'package:gopeed/app/application/app_notification_controller.dart';
import 'package:gopeed/app/application/app_platform_controller.dart';
import 'package:gopeed/app/application/app_runtime_controller.dart';
import 'package:gopeed/api/model/create_task.dart';
import 'package:gopeed/api/model/create_task_batch.dart';
import 'package:gopeed/api/model/install_extension.dart';
import 'package:gopeed/features/extensions/application/pending_extension_install.dart';
import 'package:gopeed/api/model/downloader_config.dart';
@@ -128,9 +133,9 @@ void main() {
),
),
)
..bind(GopeedMethods.createTask, (_) {
..bind(GopeedMethods.createTaskBatch, (_) {
created++;
return 'new-task';
return CreateTaskBatchResult(taskIds: ['new-task']);
});
final container = ProviderContainer(
overrides: [
@@ -3329,7 +3334,7 @@ void main() {
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: ResolveFileTree(
home: _singleResolveTree(
files: [
FileInfo(path: '', name: 'root.txt', size: 1),
FileInfo(path: 'folder', name: 'first.txt', size: 2),
@@ -3349,7 +3354,7 @@ void main() {
expect(find.text('third.txt'), findsNothing);
expect(selected, [0, 1, 2, 3]);
await tester.tap(find.byKey(const ValueKey('resolve-tree-node-expand-folder:folder/nested')));
await tester.tap(find.byKey(const ValueKey('resolve-tree-node-expand-folder:source/folder/nested')));
await tester.pumpAndSettle();
expect(find.text('second.txt'), findsOneWidget);
expect(find.text('deeper'), findsOneWidget);
@@ -3362,7 +3367,7 @@ void main() {
await tester.tap(find.byKey(const ValueKey('resolve-tree-expand-toggle')));
await tester.pumpAndSettle();
expect(find.text('first.txt'), findsNothing);
expect(find.text('root.txt'), findsOneWidget);
expect(find.text('root.txt'), findsNothing);
expect(selected, [0, 1, 2, 3]);
expect(tester.takeException(), isNull);
});
@@ -3376,7 +3381,7 @@ void main() {
materialTheme: AppTheme.materialLight(),
home: Padding(
padding: const EdgeInsets.all(24),
child: ResolveFileTree(
child: _singleResolveTree(
files: [
FileInfo(path: 'videos', name: 'clip.mp4', size: 0),
FileInfo(path: 'docs', name: 'notes.txt', size: 0),
@@ -3446,30 +3451,30 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('clip.mp4'), findsOneWidget);
final videosExpand = find.byKey(const ValueKey('resolve-tree-node-expand-folder:videos'));
final videosExpand = find.byKey(const ValueKey('resolve-tree-node-expand-folder:source/videos'));
expect(videosExpand, findsOneWidget);
expect(find.byKey(const ValueKey('resolve-tree-node-expand-file:0')), findsOneWidget);
expect(find.byKey(const ValueKey('resolve-tree-node-expand-file:source:0')), findsOneWidget);
await tester.tap(videosExpand);
await tester.pumpAndSettle();
expect(tester.widget<VirtualTreeView<dynamic>>(treeFinder).nodes.first.expanded, isFalse);
expect((tester.widget<VirtualTreeView<dynamic>>(treeFinder).nodes.first as shad.TreeItemNode<dynamic>).children.first.expanded, isFalse);
expect(find.text('clip.mp4').hitTestable(), findsNothing);
expect(find.text('videos').hitTestable(), findsOneWidget);
expect(selected, [0, 1, 2]);
await tester.tap(find.text('videos'));
await tester.pump();
expect(tester.widget<VirtualTreeView<dynamic>>(treeFinder).nodes.first.expanded, isFalse);
expect((tester.widget<VirtualTreeView<dynamic>>(treeFinder).nodes.first as shad.TreeItemNode<dynamic>).children.first.expanded, isFalse);
expect(selected, [1, 2]);
await tester.tap(find.text('videos'));
await tester.pump();
expect(tester.widget<VirtualTreeView<dynamic>>(treeFinder).nodes.first.expanded, isFalse);
expect((tester.widget<VirtualTreeView<dynamic>>(treeFinder).nodes.first as shad.TreeItemNode<dynamic>).children.first.expanded, isFalse);
expect(selected, [0, 1, 2]);
await tester.tap(videosExpand);
await tester.pumpAndSettle();
expect(tester.widget<VirtualTreeView<dynamic>>(treeFinder).nodes.first.expanded, isTrue);
expect((tester.widget<VirtualTreeView<dynamic>>(treeFinder).nodes.first as shad.TreeItemNode<dynamic>).children.first.expanded, isTrue);
expect(find.text('clip.mp4'), findsOneWidget);
BoxDecoration sortDecoration(String key) {
@@ -3520,7 +3525,7 @@ void main() {
materialTheme: AppTheme.materialLight(),
home: Padding(
padding: const EdgeInsets.all(24),
child: ResolveFileTree(
child: _singleResolveTree(
files: [
FileInfo(path: '', name: 'clip.mp4', size: 100),
FileInfo(path: '', name: 'song.mp3', size: 200),
@@ -3569,7 +3574,7 @@ void main() {
materialTheme: AppTheme.materialLight(),
home: Padding(
padding: const EdgeInsets.all(24),
child: ResolveFileTree(
child: _singleResolveTree(
files: [FileInfo(name: longName, size: 1024)],
initialSelection: const [0],
onSelectionChanged: (_) {},
@@ -3580,7 +3585,7 @@ void main() {
await tester.pumpAndSettle();
final horizontalScroll = find.byKey(const ValueKey('resolve-tree-horizontal-scroll'));
final trailing = find.byKey(const ValueKey('resolve-tree-trailing-file:0'));
final trailing = find.byKey(const ValueKey('resolve-tree-trailing-file:source:0'));
expect(find.byKey(const ValueKey('resolve-tree-horizontal-scrollbar')), findsOneWidget);
expect(horizontalScroll, findsOneWidget);
expect(trailing, findsOneWidget);
@@ -3600,7 +3605,7 @@ void main() {
position.jumpTo(0);
await tester.pumpAndSettle();
final nameArea = find.byKey(const ValueKey('resolve-tree-name-scroll-file:0'));
final nameArea = find.byKey(const ValueKey('resolve-tree-name-scroll-file:source:0'));
await tester.drag(nameArea, const Offset(-100, 0));
await tester.pumpAndSettle();
expect(position.pixels, greaterThan(0));
@@ -3622,7 +3627,7 @@ void main() {
materialTheme: AppTheme.materialLight(),
home: Padding(
padding: const EdgeInsets.all(24),
child: ResolveFileTree(
child: _singleResolveTree(
files: [FileInfo(name: shortName, size: 1024)],
initialSelection: const [0],
onSelectionChanged: (_) {},
@@ -3650,7 +3655,7 @@ void main() {
materialTheme: AppTheme.materialLight(),
home: Padding(
padding: const EdgeInsets.all(24),
child: ResolveFileTree(files: files, initialSelection: const [], onSelectionChanged: (_) {}),
child: _singleResolveTree(files: files, initialSelection: const [], onSelectionChanged: (_) {}),
),
),
);
@@ -6402,3 +6407,25 @@ api_task.Task _apiTransferTask({
updatedAt: DateTime.fromMillisecondsSinceEpoch(0),
);
}
Widget _singleResolveTree({
required List<FileInfo> files,
required List<int> initialSelection,
required ValueChanged<List<int>> onSelectionChanged,
}) {
final source =
PendingDownload(
id: 'source',
request: preview_request.Request(url: 'https://example.com'),
options: preview_options.Options(),
)
..result = preview_resolve.ResolveResult(
res: Resource(name: 'source', files: files),
)
..selected.addAll(initialSelection);
WidgetsBinding.instance.addPostFrameCallback((_) => onSelectionChanged(initialSelection));
return ResolveFileTree(
sources: [source],
onSelectionChanged: (keys) => onSelectionChanged(keys.map((key) => key.$2).toList()..sort()),
);
}