mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-04 08:10:55 +00:00
1040 lines
40 KiB
Dart
1040 lines
40 KiB
Dart
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';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
|
|
|
import '../../../../app/application/app_runtime_controller.dart';
|
|
import '../../../../core/capabilities/app_capabilities.dart';
|
|
import '../../../../core/utils/breakpoints.dart';
|
|
import '../../../../core/utils/file_explorer.dart';
|
|
import '../../../../app/router/app_router.dart';
|
|
import '../../../../core/window/app_window_chrome.dart';
|
|
import '../../../../shared/theme/app_design_tokens.dart';
|
|
import '../../../../shared/theme/app_palette.dart';
|
|
import '../../../../shared/widgets/app_toast.dart';
|
|
import '../../../../shared/widgets/responsive_navigation_layout.dart';
|
|
import '../../../../l10n/l10n.dart';
|
|
import '../../../../util/util.dart';
|
|
import '../../../tasks/application/tasks_controller.dart';
|
|
import '../../../tasks/application/task_list_navigation.dart';
|
|
import '../../../tasks/application/pending_update_task.dart';
|
|
import '../../../tasks/application/task_batch_selection_controller.dart';
|
|
import '../../../tasks/domain/task_record.dart';
|
|
import '../../../tasks/presentation/widgets/speed_monitor_card.dart';
|
|
import '../../../tasks/presentation/widgets/task_batch_selection_builder.dart';
|
|
import '../../../tasks/presentation/widgets/task_card/task_card.dart';
|
|
import '../../../tasks/presentation/widgets/task_drawer.dart';
|
|
import '../../../tasks/presentation/widgets/task_context_menu.dart';
|
|
import '../../../tasks/presentation/widgets/task_delete_dialog.dart';
|
|
import '../../../tasks/presentation/widgets/task_empty_state.dart';
|
|
import '../../../tasks/presentation/widgets/task_create_button.dart';
|
|
import '../../../tasks/presentation/widgets/task_file_browser_dialog.dart';
|
|
import '../../../tasks/presentation/widgets/task_update_url_dialog.dart';
|
|
import '../widgets/primary_rail.dart';
|
|
import '../widgets/tasks_top_bar.dart';
|
|
|
|
enum _TaskFilter { downloading, completed, failed }
|
|
|
|
class _BatchActionAvailability {
|
|
const _BatchActionAvailability({required this.canPause, required this.canResume});
|
|
|
|
final bool canPause;
|
|
final bool canResume;
|
|
}
|
|
|
|
_BatchActionAvailability _batchActionAvailability(Iterable<TaskRecord> tasks, TaskBatchSelectionController selection) {
|
|
var canPause = false;
|
|
var canResume = false;
|
|
for (final task in tasks.expand((task) => [task, ...task.members])) {
|
|
if (!selection.contains(task.id)) continue;
|
|
canPause = canPause || task.canPause;
|
|
canResume = canResume || task.canResume;
|
|
if (canPause && canResume) break;
|
|
}
|
|
return _BatchActionAvailability(canPause: canPause, canResume: canResume);
|
|
}
|
|
|
|
class HomePage extends ConsumerStatefulWidget {
|
|
const HomePage({super.key});
|
|
|
|
@override
|
|
ConsumerState<HomePage> createState() => _HomePageState();
|
|
}
|
|
|
|
class _HomePageState extends ConsumerState<HomePage> {
|
|
final TextEditingController _searchController = TextEditingController();
|
|
_TaskFilter _activeFilter = _TaskFilter.downloading;
|
|
TaskRecord? _selectedTask;
|
|
bool _batchMode = false;
|
|
final Set<String> _expandedGroupIds = {};
|
|
final TaskBatchSelectionController _batchSelection = TaskBatchSelectionController();
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_searchController.addListener(() => setState(() {}));
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_searchController.dispose();
|
|
_batchSelection.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final palette = AppPalette.of(context);
|
|
final tasksAsync = ref.watch(tasksControllerProvider);
|
|
ref.listen(taskListNavigationProvider, (_, _) {
|
|
_searchController.clear();
|
|
_setActiveFilter(_TaskFilter.downloading);
|
|
setState(() => _batchMode = false);
|
|
});
|
|
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();
|
|
final visibleTaskIds = visibleTasks.map((task) => task.id).toList(growable: false);
|
|
final pendingUpdateTask = ref.watch(pendingUpdateTaskProvider);
|
|
final navigationItems = _taskNavigationItems(tasks);
|
|
final desktopBody = shad.Scaffold(
|
|
backgroundColor: palette.bg,
|
|
child: Row(
|
|
children: [
|
|
const PrimaryRail(),
|
|
Expanded(
|
|
child: ResponsiveNavigationLayout<_TaskFilter>(
|
|
desktopTitle: context.l10n.task.toUpperCase(),
|
|
items: navigationItems,
|
|
selectedValue: _activeFilter,
|
|
onSelected: _setActiveFilter,
|
|
desktopFooter: SpeedMonitorCard(
|
|
downloadBytesPerSecond: tasksState?.downloadSpeedBytesPerSecond ?? 0,
|
|
uploadBytesPerSecond: tasksState?.uploadSpeedBytesPerSecond ?? 0,
|
|
),
|
|
child: Padding(
|
|
padding: EdgeInsets.only(
|
|
top: AppWindowChrome.reservesHeaderInset ? AppDesignTokens.windowHeaderHeight : 0,
|
|
),
|
|
child: Column(
|
|
children: [
|
|
ListenableBuilder(
|
|
listenable: _batchSelection,
|
|
builder: (context, _) {
|
|
final availability = _batchActionAvailability(tasks, _batchSelection);
|
|
return TasksTopBar(
|
|
searchController: _searchController,
|
|
onAddTask: _handleAddTask,
|
|
batchMode: _batchMode,
|
|
selectedBatchCount: _batchSelection.count,
|
|
canPauseSelected: availability.canPause,
|
|
canResumeSelected: availability.canResume,
|
|
onToggleBatchMode: _toggleBatchMode,
|
|
onPauseSelected: _pauseSelectedTasks,
|
|
onResumeSelected: _resumeSelectedTasks,
|
|
onDeleteSelected: _deleteSelectedTasks,
|
|
);
|
|
},
|
|
),
|
|
Expanded(
|
|
child: Stack(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(32, AppDesignTokens.space8, 32, 0),
|
|
child: Column(
|
|
key: const ValueKey('tasks-content-start'),
|
|
children: [
|
|
AnimatedSize(
|
|
duration: const Duration(milliseconds: 180),
|
|
curve: Curves.easeOutCubic,
|
|
child: _batchMode
|
|
? ListenableBuilder(
|
|
listenable: _batchSelection,
|
|
builder: (context, _) => _TaskBatchRow(
|
|
selectedCount: _batchSelection.count,
|
|
totalCount: visibleTasks.length,
|
|
allSelected: _batchSelection.areAllSelected(visibleTaskIds),
|
|
someSelected:
|
|
_batchSelection.isNotEmpty &&
|
|
!_batchSelection.areAllSelected(visibleTaskIds),
|
|
onToggleSelectAll: _toggleSelectAll,
|
|
),
|
|
)
|
|
: const SizedBox.shrink(),
|
|
),
|
|
Expanded(
|
|
child: tasksAsync.when(
|
|
loading: () => const Center(child: shad.CircularProgressIndicator()),
|
|
error: (error, _) => const TaskEmptyState(),
|
|
data: (_) => filteredTasks.isEmpty
|
|
? TaskEmptyState(
|
|
message: emptyStateMessage,
|
|
onCreateTask: _searchController.text.trim().isEmpty ? _handleAddTask : null,
|
|
)
|
|
: ListView.separated(
|
|
padding: const EdgeInsets.only(bottom: 16),
|
|
itemCount: visibleTasks.length,
|
|
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
|
itemBuilder: (context, 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,
|
|
onPressed: () => _handleTaskPressed(task),
|
|
onToggleBatch: () => _toggleBatchTask(task.id),
|
|
onPause: () => _runTaskAction(
|
|
() => ref.read(tasksControllerProvider.notifier).pause(task.id),
|
|
),
|
|
onResume: () => _runTaskAction(
|
|
() => ref.read(tasksControllerProvider.notifier).resume(task.id),
|
|
),
|
|
onDelete: () => unawaited(_deleteTasks([task.id])),
|
|
onOpen: () => _openTask(task),
|
|
onReveal: () => _revealTask(task),
|
|
contextActions: _taskContextActions(
|
|
task,
|
|
selected: selectedInBatch,
|
|
allSelected: allSelected,
|
|
),
|
|
listeningForUpdate: pendingUpdateTask?.id == task.id,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
TaskDrawer(
|
|
task: selectedTask,
|
|
onClose: () => setState(() => _selectedTask = null),
|
|
onOpenStorage: selectedTask == null ? () {} : () => unawaited(_revealTask(selectedTask)),
|
|
onUpdateUrl: selectedTask == null
|
|
? (_) async {}
|
|
: (url) => _updateTaskUrl(selectedTask.id, url, headers: selectedTask.requestHeaders),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (MediaQuery.sizeOf(context).width < Breakpoints.mobile) {
|
|
final mobileBody = tasksAsync.when(
|
|
loading: () => _MobileTasksView(
|
|
tasks: const [],
|
|
memberIds: const {},
|
|
expandedGroupIds: _expandedGroupIds,
|
|
batchMode: _batchMode,
|
|
batchSelection: _batchSelection,
|
|
loading: true,
|
|
onToggleSelectAll: _toggleSelectAll,
|
|
onPauseSelected: _pauseSelectedTasks,
|
|
onResumeSelected: _resumeSelectedTasks,
|
|
onDeleteSelected: _deleteSelectedTasks,
|
|
onTaskPressed: _handleTaskPressed,
|
|
onToggleBatch: _toggleBatchTask,
|
|
onTaskPause: (task) => _runTaskAction(() => ref.read(tasksControllerProvider.notifier).pause(task.id)),
|
|
onTaskResume: (task) => _runTaskAction(() => ref.read(tasksControllerProvider.notifier).resume(task.id)),
|
|
onTaskDelete: (task) => unawaited(_deleteTasks([task.id])),
|
|
onTaskOpen: _openTask,
|
|
onTaskReveal: _revealTask,
|
|
contextActionsForTask: (task, selected, allSelected) =>
|
|
_taskContextActions(task, selected: selected, allSelected: allSelected),
|
|
pendingUpdateTaskId: pendingUpdateTask?.id,
|
|
),
|
|
error: (error, _) => ColoredBox(color: palette.bg, child: const TaskEmptyState()),
|
|
data: (_) => _MobileTasksView(
|
|
tasks: visibleTasks,
|
|
memberIds: memberIds,
|
|
expandedGroupIds: _expandedGroupIds,
|
|
emptyStateMessage: emptyStateMessage,
|
|
onCreateTask: _searchController.text.trim().isEmpty ? _handleAddTask : null,
|
|
batchMode: _batchMode,
|
|
batchSelection: _batchSelection,
|
|
onToggleSelectAll: _toggleSelectAll,
|
|
onPauseSelected: _pauseSelectedTasks,
|
|
onResumeSelected: _resumeSelectedTasks,
|
|
onDeleteSelected: _deleteSelectedTasks,
|
|
onTaskPressed: _handleTaskPressed,
|
|
onToggleBatch: _toggleBatchTask,
|
|
onTaskPause: (task) => _runTaskAction(() => ref.read(tasksControllerProvider.notifier).pause(task.id)),
|
|
onTaskResume: (task) => _runTaskAction(() => ref.read(tasksControllerProvider.notifier).resume(task.id)),
|
|
onTaskDelete: (task) => unawaited(_deleteTasks([task.id])),
|
|
onTaskOpen: _openTask,
|
|
onTaskReveal: _revealTask,
|
|
contextActionsForTask: (task, selected, allSelected) =>
|
|
_taskContextActions(task, selected: selected, allSelected: allSelected),
|
|
pendingUpdateTaskId: pendingUpdateTask?.id,
|
|
),
|
|
);
|
|
return shad.Scaffold(
|
|
backgroundColor: palette.bg,
|
|
child: Column(
|
|
children: [
|
|
Expanded(
|
|
child: ResponsiveNavigationLayout<_TaskFilter>(
|
|
desktopTitle: context.l10n.task.toUpperCase(),
|
|
items: navigationItems,
|
|
selectedValue: _activeFilter,
|
|
onSelected: _setActiveFilter,
|
|
mobileHeader: ListenableBuilder(
|
|
listenable: _batchSelection,
|
|
builder: (context, _) => _MobileTasksHeader(
|
|
batchMode: _batchMode,
|
|
selectedCount: _batchSelection.count,
|
|
onAddTask: _handleAddTask,
|
|
onToggleBatchMode: _toggleBatchMode,
|
|
),
|
|
),
|
|
child: mobileBody,
|
|
),
|
|
),
|
|
const PrimaryBottomNavigation(),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
return desktopBody;
|
|
}
|
|
|
|
void _setActiveFilter(_TaskFilter filter) {
|
|
_batchSelection.clear();
|
|
setState(() {
|
|
_activeFilter = filter;
|
|
_selectedTask = null;
|
|
});
|
|
}
|
|
|
|
List<TaskRecord> _filteredTasks(List<TaskRecord> tasks) {
|
|
final query = _searchController.text.trim().toLowerCase();
|
|
return tasks
|
|
.where((task) {
|
|
final passesFilter = switch (_activeFilter) {
|
|
_TaskFilter.downloading => task.status == TaskStatus.downloading || task.status == TaskStatus.paused,
|
|
_TaskFilter.completed => task.status == TaskStatus.completed,
|
|
_TaskFilter.failed => task.status == TaskStatus.failed,
|
|
};
|
|
final passesSearch = task.matchesSearch(query);
|
|
return passesFilter && passesSearch;
|
|
})
|
|
.toList(growable: false);
|
|
}
|
|
|
|
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;
|
|
return ref.read(tasksControllerProvider).value?.findTask(selectedTask.id);
|
|
}
|
|
|
|
List<ResponsiveNavigationItem<_TaskFilter>> _taskNavigationItems(List<TaskRecord> tasks) {
|
|
int count(bool Function(TaskRecord task) test) => tasks.where(test).length;
|
|
|
|
return [
|
|
ResponsiveNavigationItem<_TaskFilter>(
|
|
value: _TaskFilter.downloading,
|
|
label: context.l10n.downloading,
|
|
icon: Icons.south_rounded,
|
|
count: count((task) => task.status == TaskStatus.downloading || task.status == TaskStatus.paused),
|
|
),
|
|
ResponsiveNavigationItem<_TaskFilter>(
|
|
value: _TaskFilter.completed,
|
|
label: context.l10n.completed,
|
|
icon: Icons.check_circle_outline,
|
|
count: count((task) => task.status == TaskStatus.completed),
|
|
),
|
|
ResponsiveNavigationItem<_TaskFilter>(
|
|
value: _TaskFilter.failed,
|
|
label: context.l10n.failed,
|
|
icon: Icons.error_outline,
|
|
count: count((task) => task.status == TaskStatus.failed),
|
|
),
|
|
];
|
|
}
|
|
|
|
void _handleTaskPressed(TaskRecord task) {
|
|
if (_batchMode) {
|
|
_toggleBatchTask(task.id);
|
|
} else {
|
|
if (task.isGroup) {
|
|
setState(() {
|
|
if (!_expandedGroupIds.remove(task.id)) _expandedGroupIds.add(task.id);
|
|
});
|
|
} else {
|
|
_showTaskDetails(task);
|
|
}
|
|
}
|
|
}
|
|
|
|
void _showTaskDetails(TaskRecord task) {
|
|
if (MediaQuery.sizeOf(context).width < Breakpoints.mobile) {
|
|
context.push('/tasks/${Uri.encodeComponent(task.id)}', extra: task);
|
|
} else {
|
|
setState(() {
|
|
_selectedTask = task;
|
|
});
|
|
}
|
|
}
|
|
|
|
void _toggleBatchMode() {
|
|
final enteringBatchMode = !_batchMode;
|
|
if (!enteringBatchMode) {
|
|
_batchSelection.clear();
|
|
}
|
|
setState(() {
|
|
_batchMode = enteringBatchMode;
|
|
if (_batchMode) {
|
|
_selectedTask = null;
|
|
}
|
|
});
|
|
}
|
|
|
|
void _toggleBatchTask(String taskId) => _batchSelection.toggle(taskId);
|
|
|
|
void _toggleSelectAll() {
|
|
final visibleIds = _visibleTasks(
|
|
_filteredTasks(ref.read(tasksControllerProvider).value?.tasks ?? const []),
|
|
).map((task) => task.id).toList(growable: false);
|
|
if (_batchSelection.areAllSelected(visibleIds)) {
|
|
_batchSelection.clear();
|
|
return;
|
|
}
|
|
if (!_batchMode) {
|
|
setState(() {
|
|
_batchMode = true;
|
|
_selectedTask = null;
|
|
});
|
|
}
|
|
_batchSelection.replaceWith(visibleIds);
|
|
}
|
|
|
|
void _deleteSelectedTasks() {
|
|
if (_batchSelection.isEmpty) return;
|
|
final ids = _batchSelection.toList();
|
|
unawaited(_deleteTasks(ids));
|
|
}
|
|
|
|
void _pauseSelectedTasks() {
|
|
if (_batchMode && _batchSelection.isEmpty) return;
|
|
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.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?.allTasks ?? const <TaskRecord>[];
|
|
return tasks.where((task) => _batchSelection.contains(task.id) && test(task)).map((task) => task.id).toList();
|
|
}
|
|
|
|
TaskContextActions _taskContextActions(TaskRecord task, {required bool selected, required bool allSelected}) {
|
|
final listeningForUpdate = ref.read(pendingUpdateTaskProvider)?.id == task.id;
|
|
return TaskContextActions(
|
|
selected: selected,
|
|
allSelected: allSelected,
|
|
listeningForUpdate: listeningForUpdate,
|
|
onShowDetails: () => _showTaskDetails(task),
|
|
onOpenFile: () => unawaited(_openTask(task)),
|
|
onOpenDirectory: () => unawaited(_revealTask(task)),
|
|
onBrowseFiles: () =>
|
|
unawaited(browseTaskFiles(context, task, mobile: MediaQuery.sizeOf(context).width < Breakpoints.mobile)),
|
|
onToggleSelectAll: _toggleSelectAll,
|
|
onToggleSelected: () {
|
|
if (!_batchMode) {
|
|
setState(() {
|
|
_batchMode = true;
|
|
_selectedTask = null;
|
|
});
|
|
}
|
|
_toggleBatchTask(task.id);
|
|
},
|
|
onPause: () => unawaited(_runContextTaskAction(task, pause: true)),
|
|
onResume: () => unawaited(_runContextTaskAction(task, pause: false)),
|
|
onDelete: () => unawaited(_deleteTasks(_contextTaskIds(task))),
|
|
onUpdateUrl: () => unawaited(_showUpdateUrlDialog(task)),
|
|
onToggleUpdateListener: () => _toggleUpdateListener(task),
|
|
);
|
|
}
|
|
|
|
List<String> _contextTaskIds(TaskRecord task) {
|
|
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);
|
|
}
|
|
|
|
Future<void> _runContextTaskAction(TaskRecord task, {required bool pause}) async {
|
|
final ids = _contextTaskIds(task);
|
|
await _runTaskAction(() {
|
|
final controller = ref.read(tasksControllerProvider.notifier);
|
|
return pause ? controller.pauseAll(ids) : controller.resumeAll(ids);
|
|
});
|
|
if (!mounted) return;
|
|
_batchSelection.clear();
|
|
setState(() {
|
|
_batchMode = false;
|
|
});
|
|
}
|
|
|
|
Future<void> _deleteTasks(List<String> ids) async {
|
|
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;
|
|
|
|
await _runTaskAction(() async {
|
|
if (config != null) {
|
|
config.extra.lastDeleteTaskKeep = keepFiles;
|
|
await ref.read(gopeedServiceProvider).putConfig(config);
|
|
}
|
|
await ref.read(tasksControllerProvider.notifier).deleteSelected(ids, force: !keepFiles);
|
|
await ref.read(appRuntimeControllerProvider.notifier).reloadConfig();
|
|
});
|
|
if (!mounted) return;
|
|
_batchSelection.removeAll({...ids, ...groupedMemberIds});
|
|
setState(() {
|
|
_batchMode = false;
|
|
_expandedGroupIds.removeAll(ids);
|
|
if (_selectedTask != null && (ids.contains(_selectedTask!.id) || groupedMemberIds.contains(_selectedTask!.id))) {
|
|
_selectedTask = null;
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _showUpdateUrlDialog(TaskRecord task) async {
|
|
final update = await showTaskUpdateUrlDialog(context, task);
|
|
if (update == null || !mounted) return;
|
|
await _updateTaskUrl(task.id, update.url, headers: update.headers);
|
|
}
|
|
|
|
void _toggleUpdateListener(TaskRecord task) {
|
|
if (!task.canUpdateUrl) return;
|
|
final notifier = ref.read(pendingUpdateTaskProvider.notifier);
|
|
if (ref.read(pendingUpdateTaskProvider)?.id == task.id) {
|
|
notifier.clear();
|
|
} else {
|
|
notifier.set(PendingUpdateTask(id: task.id, name: task.name));
|
|
}
|
|
}
|
|
|
|
void _handleAddTask() => unawaited(AppRouter.openCreateTask());
|
|
|
|
Future<void> _runTaskAction(Future<void> Function() action) async {
|
|
try {
|
|
await action();
|
|
} catch (error) {
|
|
if (!mounted) return;
|
|
_showToast(error.toString());
|
|
}
|
|
}
|
|
|
|
Future<void> _updateTaskUrl(String id, String url, {Map<String, String> headers = const {}}) async {
|
|
try {
|
|
await ref.read(tasksControllerProvider.notifier).updateUrl(id, url, headers: headers);
|
|
if (mounted) {
|
|
setState(() => _selectedTask = ref.read(tasksControllerProvider).value?.findTask(id));
|
|
}
|
|
} catch (error) {
|
|
if (!mounted) rethrow;
|
|
_showToast(error.toString());
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
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);
|
|
if (!opened && mounted) {
|
|
_showToast(context.l10n.unableOpenPath(task.storagePath));
|
|
}
|
|
}
|
|
|
|
Future<void> _revealTask(TaskRecord task) async {
|
|
if (!await FileExplorer.reveal(task.storagePath) && mounted) {
|
|
_showToast(context.l10n.unableLocatePath(task.storagePath));
|
|
}
|
|
}
|
|
|
|
void _showToast(String message) {
|
|
showAppToast(context, message, type: AppToastType.error);
|
|
}
|
|
}
|
|
|
|
class _TaskBatchRow extends StatelessWidget {
|
|
const _TaskBatchRow({
|
|
required this.selectedCount,
|
|
required this.totalCount,
|
|
required this.allSelected,
|
|
required this.someSelected,
|
|
required this.onToggleSelectAll,
|
|
});
|
|
|
|
final int selectedCount;
|
|
final int totalCount;
|
|
final bool allSelected;
|
|
final bool someSelected;
|
|
final VoidCallback onToggleSelectAll;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final palette = AppPalette.of(context);
|
|
|
|
return Container(
|
|
height: 48,
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
decoration: BoxDecoration(
|
|
border: Border(bottom: BorderSide(color: palette.border)),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
GestureDetector(
|
|
onTap: onToggleSelectAll,
|
|
child: Row(
|
|
children: [
|
|
shad.Checkbox(
|
|
state: allSelected
|
|
? shad.CheckboxState.checked
|
|
: someSelected
|
|
? shad.CheckboxState.indeterminate
|
|
: shad.CheckboxState.unchecked,
|
|
onChanged: (_) => onToggleSelectAll(),
|
|
borderColor: palette.border,
|
|
backgroundColor: palette.cardBg,
|
|
size: 20,
|
|
),
|
|
const SizedBox(width: 14),
|
|
Text(
|
|
context.l10n.selectAll.toUpperCase(),
|
|
style: TextStyle(
|
|
color: palette.textMuted,
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: 1.1,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const Spacer(),
|
|
Text(
|
|
context.l10n.selectedCount(selectedCount, totalCount).toUpperCase(),
|
|
style: TextStyle(color: palette.textMuted, fontSize: 11, fontWeight: FontWeight.w700, letterSpacing: 1.1),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MobileTasksView extends StatelessWidget {
|
|
const _MobileTasksView({
|
|
required this.tasks,
|
|
required this.memberIds,
|
|
required this.expandedGroupIds,
|
|
required this.batchMode,
|
|
required this.batchSelection,
|
|
required this.onToggleSelectAll,
|
|
required this.onPauseSelected,
|
|
required this.onResumeSelected,
|
|
required this.onDeleteSelected,
|
|
required this.onTaskPressed,
|
|
required this.onToggleBatch,
|
|
required this.onTaskPause,
|
|
required this.onTaskResume,
|
|
required this.onTaskDelete,
|
|
required this.onTaskOpen,
|
|
required this.onTaskReveal,
|
|
required this.contextActionsForTask,
|
|
required this.pendingUpdateTaskId,
|
|
this.emptyStateMessage,
|
|
this.onCreateTask,
|
|
this.loading = false,
|
|
});
|
|
|
|
final List<TaskRecord> tasks;
|
|
final Set<String> memberIds;
|
|
final Set<String> expandedGroupIds;
|
|
final bool batchMode;
|
|
final TaskBatchSelectionController batchSelection;
|
|
final VoidCallback onToggleSelectAll;
|
|
final VoidCallback onPauseSelected;
|
|
final VoidCallback onResumeSelected;
|
|
final VoidCallback onDeleteSelected;
|
|
final ValueChanged<TaskRecord> onTaskPressed;
|
|
final ValueChanged<String> onToggleBatch;
|
|
final ValueChanged<TaskRecord> onTaskPause;
|
|
final ValueChanged<TaskRecord> onTaskResume;
|
|
final ValueChanged<TaskRecord> onTaskDelete;
|
|
final ValueChanged<TaskRecord> onTaskOpen;
|
|
final ValueChanged<TaskRecord> onTaskReveal;
|
|
final TaskContextActions Function(TaskRecord task, bool selected, bool allSelected) contextActionsForTask;
|
|
final String? pendingUpdateTaskId;
|
|
final String? emptyStateMessage;
|
|
final VoidCallback? onCreateTask;
|
|
final bool loading;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final palette = AppPalette.of(context);
|
|
final visibleTaskIds = tasks.map((task) => task.id).toList(growable: false);
|
|
|
|
return ColoredBox(
|
|
color: palette.bg,
|
|
child: Column(
|
|
children: [
|
|
AnimatedSize(
|
|
duration: const Duration(milliseconds: 180),
|
|
curve: Curves.easeOutCubic,
|
|
child: batchMode
|
|
? ListenableBuilder(
|
|
listenable: batchSelection,
|
|
builder: (context, _) {
|
|
final allSelected = batchSelection.areAllSelected(visibleTaskIds);
|
|
final availability = _batchActionAvailability(tasks, batchSelection);
|
|
return _MobileBatchToolbar(
|
|
selectedCount: batchSelection.count,
|
|
visibleCount: tasks.length,
|
|
allSelected: allSelected,
|
|
someSelected: batchSelection.isNotEmpty && !allSelected,
|
|
canPauseSelected: availability.canPause,
|
|
canResumeSelected: availability.canResume,
|
|
onToggleSelectAll: onToggleSelectAll,
|
|
onPauseSelected: onPauseSelected,
|
|
onResumeSelected: onResumeSelected,
|
|
onDeleteSelected: onDeleteSelected,
|
|
);
|
|
},
|
|
)
|
|
: const SizedBox.shrink(),
|
|
),
|
|
Expanded(
|
|
child: loading
|
|
? const Center(child: shad.CircularProgressIndicator())
|
|
: tasks.isEmpty
|
|
? TaskEmptyState(message: emptyStateMessage, onCreateTask: onCreateTask)
|
|
: ListView.separated(
|
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
|
itemCount: tasks.length,
|
|
separatorBuilder: (_, _) => const SizedBox(height: 6),
|
|
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,
|
|
onPressed: () => onTaskPressed(task),
|
|
onToggleBatch: () => onToggleBatch(task.id),
|
|
onPause: () => onTaskPause(task),
|
|
onResume: () => onTaskResume(task),
|
|
onDelete: () => onTaskDelete(task),
|
|
onOpen: () => onTaskOpen(task),
|
|
onReveal: () => onTaskReveal(task),
|
|
contextActions: contextActionsForTask(task, selectedInBatch, allSelected),
|
|
listeningForUpdate: pendingUpdateTaskId == task.id,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MobileTasksHeader extends StatelessWidget {
|
|
const _MobileTasksHeader({
|
|
required this.batchMode,
|
|
required this.selectedCount,
|
|
required this.onAddTask,
|
|
required this.onToggleBatchMode,
|
|
});
|
|
|
|
final bool batchMode;
|
|
final int selectedCount;
|
|
final VoidCallback onAddTask;
|
|
final VoidCallback onToggleBatchMode;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final palette = AppPalette.of(context);
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: palette.bg,
|
|
border: Border(bottom: BorderSide(color: palette.border)),
|
|
),
|
|
child: SafeArea(
|
|
bottom: false,
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
context.l10n.taskList,
|
|
style: TextStyle(color: palette.textPrimary, fontSize: 22, fontWeight: FontWeight.w800),
|
|
),
|
|
),
|
|
const SizedBox(width: AppDesignTokens.space8),
|
|
ConstrainedBox(
|
|
constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.5),
|
|
child: TaskCreateButton(
|
|
key: const ValueKey('tasks-mobile-create-button'),
|
|
onPressed: onAddTask,
|
|
minHeight: 34,
|
|
compact: true,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
_MobileHeaderIconButton(
|
|
key: const ValueKey('tasks-mobile-batch-button'),
|
|
icon: Icons.checklist_rtl_outlined,
|
|
active: batchMode,
|
|
badge: batchMode && selectedCount > 0 ? selectedCount.toString() : null,
|
|
onPressed: onToggleBatchMode,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MobileBatchToolbar extends StatelessWidget {
|
|
const _MobileBatchToolbar({
|
|
required this.selectedCount,
|
|
required this.visibleCount,
|
|
required this.allSelected,
|
|
required this.someSelected,
|
|
required this.canPauseSelected,
|
|
required this.canResumeSelected,
|
|
required this.onToggleSelectAll,
|
|
required this.onPauseSelected,
|
|
required this.onResumeSelected,
|
|
required this.onDeleteSelected,
|
|
});
|
|
|
|
final int selectedCount;
|
|
final int visibleCount;
|
|
final bool allSelected;
|
|
final bool someSelected;
|
|
final bool canPauseSelected;
|
|
final bool canResumeSelected;
|
|
final VoidCallback onToggleSelectAll;
|
|
final VoidCallback onPauseSelected;
|
|
final VoidCallback onResumeSelected;
|
|
final VoidCallback onDeleteSelected;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final palette = AppPalette.of(context);
|
|
return Container(
|
|
height: 48,
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
decoration: BoxDecoration(
|
|
color: palette.sideBg,
|
|
border: Border(bottom: BorderSide(color: palette.border)),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: onToggleSelectAll,
|
|
child: Row(
|
|
children: [
|
|
shad.Checkbox(
|
|
state: allSelected
|
|
? shad.CheckboxState.checked
|
|
: someSelected
|
|
? shad.CheckboxState.indeterminate
|
|
: shad.CheckboxState.unchecked,
|
|
onChanged: (_) => onToggleSelectAll(),
|
|
borderColor: palette.border,
|
|
backgroundColor: palette.cardBg,
|
|
),
|
|
const SizedBox(width: AppDesignTokens.checkboxLabelGap),
|
|
Text(
|
|
'$selectedCount / $visibleCount',
|
|
style: TextStyle(color: palette.textSecondary, fontSize: 12, fontWeight: FontWeight.w700),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const Spacer(),
|
|
_MobileIconAction(icon: Icons.pause_outlined, onPressed: onPauseSelected, enabled: canPauseSelected),
|
|
const SizedBox(width: 6),
|
|
_MobileIconAction(icon: Icons.play_arrow_outlined, onPressed: onResumeSelected, enabled: canResumeSelected),
|
|
const SizedBox(width: 6),
|
|
_MobileIconAction(icon: Icons.delete_outline, onPressed: onDeleteSelected, enabled: selectedCount > 0),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MobileHeaderIconButton extends StatelessWidget {
|
|
const _MobileHeaderIconButton({
|
|
super.key,
|
|
required this.icon,
|
|
required this.onPressed,
|
|
this.active = false,
|
|
this.badge,
|
|
});
|
|
|
|
final IconData icon;
|
|
final VoidCallback onPressed;
|
|
final bool active;
|
|
final String? badge;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final palette = AppPalette.of(context);
|
|
return GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: onPressed,
|
|
child: Container(
|
|
width: 34,
|
|
height: 34,
|
|
decoration: BoxDecoration(
|
|
color: active ? palette.itemActiveBg : palette.cardBg,
|
|
borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius),
|
|
border: Border.all(color: active ? palette.textPrimary : palette.border),
|
|
),
|
|
child: Stack(
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
Center(child: Icon(icon, size: 18, color: active ? palette.textPrimary : palette.textSecondary)),
|
|
if (badge != null)
|
|
Positioned(
|
|
right: -5,
|
|
top: -5,
|
|
child: Container(
|
|
constraints: const BoxConstraints(minWidth: 16),
|
|
height: 16,
|
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
|
decoration: BoxDecoration(color: palette.brand, borderRadius: BorderRadius.circular(999)),
|
|
child: Center(
|
|
child: Text(
|
|
badge!,
|
|
style: TextStyle(color: palette.brandForeground, fontSize: 9, fontWeight: FontWeight.w800),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MobileIconAction extends StatelessWidget {
|
|
const _MobileIconAction({required this.icon, required this.onPressed, required this.enabled});
|
|
|
|
final IconData icon;
|
|
final VoidCallback onPressed;
|
|
final bool enabled;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final palette = AppPalette.of(context);
|
|
return GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: enabled ? onPressed : null,
|
|
child: Container(
|
|
width: 32,
|
|
height: 32,
|
|
decoration: BoxDecoration(
|
|
color: enabled ? palette.cardBg : Colors.transparent,
|
|
borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius),
|
|
border: Border.all(color: enabled ? palette.border : Colors.transparent),
|
|
),
|
|
child: Icon(icon, size: 18, color: enabled ? palette.textSecondary : palette.textMuted),
|
|
),
|
|
);
|
|
}
|
|
}
|