mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-13 20:03:46 +00:00
feat(tasks): group extension folder downloads (#1594)
This commit is contained in:
1 parent
fc0a1e0ef9
commit
1e36599d52
84 files changed
+6690
-881
No files matched your search
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+19
-11
@@ -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(
|
||||
|
||||
+19
-11
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user