Files
plezy/lib/widgets/download_tree_view.dart
T
edde746 352b88109b refactor: extract shared mixins and helpers, drop dead abstractions
Introduces shared seams for paginated views, D-pad reorder, media control
routing, async singletons and the device method channel, then points the
open-coded copies at them.

Also removes unused models and duplicated provider/server plumbing, folds
the twice-implemented artifact store in the server, and factors the
repeated Flutter toolchain prologue in CI into a composite action.
2026-07-26 06:09:48 +02:00

911 lines
30 KiB
Dart

import 'package:flutter/material.dart';
import '../media/ids.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../focus/focusable_wrapper.dart';
import '../i18n/strings.g.dart';
import '../media/media_item.dart';
import '../media/media_item_types.dart';
import '../media/media_kind.dart';
import '../models/download_models.dart';
import '../utils/dialogs.dart';
import '../utils/global_key_utils.dart';
import 'download_status_icon.dart';
/// Represents a node in the download tree
class DownloadTreeNode {
final String key;
final String title;
final DownloadNodeType type;
final double progress; // 0.0-1.0
final DownloadStatus status;
final List<DownloadTreeNode> children;
final MediaItem? metadata;
final DownloadProgress? downloadProgress;
const DownloadTreeNode({
required this.key,
required this.title,
required this.type,
this.progress = 0.0,
required this.status,
this.children = const [],
this.metadata,
this.downloadProgress,
});
/// Check if this node has children
bool get hasChildren => children.isNotEmpty;
/// Get the number of completed children
int get completedChildrenCount {
return children.where((child) => child.status == DownloadStatus.completed).length;
}
}
/// Type of node in the download tree
enum DownloadNodeType { show, season, episode, movie, album, track }
/// Hierarchical tree view for downloads
/// Groups TV shows by show -> season -> episode and music by album -> track
/// Movies appear at top level
class DownloadTreeView extends StatefulWidget {
final Map<String, DownloadProgress> downloads;
final Map<String, MediaItem> metadata;
final void Function(String globalKey)? onPause;
final void Function(String globalKey)? onResume;
final void Function(String globalKey)? onRetry;
final void Function(String globalKey)? onCancel;
final void Function(String globalKey)? onDelete;
final VoidCallback? onNavigateLeft;
final VoidCallback? onBack;
final bool suppressAutoFocus;
const DownloadTreeView({
super.key,
required this.downloads,
required this.metadata,
this.onPause,
this.onResume,
this.onRetry,
this.onCancel,
this.onDelete,
this.onNavigateLeft,
this.onBack,
this.suppressAutoFocus = false,
});
@override
State<DownloadTreeView> createState() => _DownloadTreeViewState();
}
class _DownloadTreeViewState extends State<DownloadTreeView> {
final Set<String> _expandedNodes = {};
final FocusNode _firstItemFocusNode = FocusNode(debugLabel: 'DownloadTreeView_firstItem');
@override
void dispose() {
_firstItemFocusNode.dispose();
super.dispose();
}
@override
void didUpdateWidget(DownloadTreeView oldWidget) {
super.didUpdateWidget(oldWidget);
// When suppressAutoFocus changes from true to false, focus the first item
if (oldWidget.suppressAutoFocus && !widget.suppressAutoFocus) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _firstItemFocusNode.canRequestFocus) {
_firstItemFocusNode.requestFocus();
}
});
}
}
@override
Widget build(BuildContext context) {
final tree = _buildTree();
final flattenedNodes = _flattenTree(tree);
if (flattenedNodes.isEmpty) {
return Center(child: Text(t.downloads.noDownloadsTree));
}
return ListView.builder(
padding: .zero,
itemCount: flattenedNodes.length,
itemBuilder: (context, index) {
final item = flattenedNodes[index];
return _buildTreeItem(item.node, item.depth, isFirst: index == 0);
},
);
}
/// Build the download tree from flat download list
List<DownloadTreeNode> _buildTree() {
final Map<String, List<MapEntry<String, DownloadProgress>>> showGroups = {};
final Map<String, List<MapEntry<String, DownloadProgress>>> albumGroups = {};
final List<DownloadTreeNode> movies = [];
// Group downloads
for (final entry in widget.downloads.entries) {
final globalKey = entry.key;
final download = entry.value;
final meta = widget.metadata[globalKey];
if (meta == null) continue;
if (meta.isEpisode) {
// Group episodes by show
final showKey = meta.grandparentId ?? 'unknown';
showGroups.putIfAbsent(showKey, () => []);
showGroups[showKey]!.add(entry);
} else if (meta.kind == MediaKind.track) {
// Group tracks by album (single level — no per-disc tier)
final albumKey = meta.parentId ?? 'unknown';
albumGroups.putIfAbsent(albumKey, () => []);
albumGroups[albumKey]!.add(entry);
} else if (meta.isMovie) {
// Movies go at top level
movies.add(
DownloadTreeNode(
key: globalKey,
title: meta.displayTitle,
type: DownloadNodeType.movie,
progress: download.progressPercent,
status: download.status,
metadata: meta,
downloadProgress: download,
),
);
}
}
// Build show nodes
final List<DownloadTreeNode> shows = [];
for (final showEntry in showGroups.entries) {
final showKey = showEntry.key;
final episodes = showEntry.value;
if (episodes.isEmpty) continue;
// Get show metadata from first episode
final firstEpisode = widget.metadata[episodes.first.key];
final showTitle = firstEpisode?.grandparentTitle ?? 'Unknown Show';
// Group episodes by season
final Map<String, List<MapEntry<String, DownloadProgress>>> seasonGroups = {};
for (final episode in episodes) {
final meta = widget.metadata[episode.key];
if (meta == null) continue;
final seasonKey = meta.parentId ?? 'unknown';
seasonGroups.putIfAbsent(seasonKey, () => []);
seasonGroups[seasonKey]!.add(episode);
}
// Build season nodes
final List<DownloadTreeNode> seasons = [];
for (final seasonEntry in seasonGroups.entries) {
final seasonKey = seasonEntry.key;
final seasonEpisodes = seasonEntry.value;
if (seasonEpisodes.isEmpty) continue;
// Get season metadata from first episode
final firstEpisode = widget.metadata[seasonEpisodes.first.key];
final seasonNumber = firstEpisode?.parentIndex;
final seasonTitle = firstEpisode?.parentTitle?.isNotEmpty == true
? firstEpisode!.parentTitle!
: seasonNumber != null
? t.common.seasonNumber(number: seasonNumber)
: 'Unknown Season';
// Build episode nodes
final List<DownloadTreeNode> episodeNodes = [];
for (final episodeEntry in seasonEpisodes) {
final globalKey = episodeEntry.key;
final download = episodeEntry.value;
final meta = widget.metadata[globalKey];
if (meta == null) continue;
final episodeNumber = meta.index;
final episodeTitle = episodeNumber != null
? t.common.episodeNumberTitle(number: episodeNumber, title: meta.title!)
: meta.title!;
episodeNodes.add(
DownloadTreeNode(
key: globalKey,
title: episodeTitle,
type: DownloadNodeType.episode,
progress: download.progressPercent,
status: download.status,
metadata: meta,
downloadProgress: download,
),
);
}
// Sort episodes by episode number only (not by status)
episodeNodes.sort((a, b) {
final aIndex = a.metadata?.index ?? 0;
final bIndex = b.metadata?.index ?? 0;
return aIndex.compareTo(bIndex);
});
// Calculate aggregate season progress
final seasonProgress = episodeNodes.isEmpty
? 0.0
: episodeNodes.map((e) => e.progress).reduce((a, b) => a + b) / episodeNodes.length;
final seasonStatus = _determineAggregateStatus(episodeNodes.map((e) => e.status).toList());
seasons.add(
DownloadTreeNode(
key: '$showKey:$seasonKey',
title: seasonTitle,
type: DownloadNodeType.season,
progress: seasonProgress,
status: seasonStatus,
children: episodeNodes,
),
);
}
seasons.removeWhere((s) => s.children.isEmpty);
// Sort seasons by season number
seasons.sort((a, b) {
final aSeasonNum = widget.metadata[a.children.first.key]?.parentIndex ?? 0;
final bSeasonNum = widget.metadata[b.children.first.key]?.parentIndex ?? 0;
return aSeasonNum.compareTo(bSeasonNum);
});
// Calculate aggregate show progress
final showProgress = seasons.isEmpty
? 0.0
: seasons.map((s) => s.progress).reduce((a, b) => a + b) / seasons.length;
final showStatus = _determineAggregateStatus(seasons.map((s) => s.status).toList());
shows.add(
DownloadTreeNode(
key: showKey,
title: showTitle,
type: DownloadNodeType.show,
progress: showProgress,
status: showStatus,
children: seasons,
),
);
}
// Build album nodes (album -> tracks)
final List<DownloadTreeNode> albums = [];
for (final albumEntry in albumGroups.entries) {
final albumKey = albumEntry.key;
final tracks = albumEntry.value;
if (tracks.isEmpty) continue;
// Album/artist names from any track's parent fields
final firstTrack = widget.metadata[tracks.first.key];
final albumTitle = firstTrack?.albumTitle ?? 'Unknown Album';
final artistTitle = firstTrack?.albumArtistTitle;
final albumNodeTitle = artistTitle != null && artistTitle.isNotEmpty ? '$artistTitle - $albumTitle' : albumTitle;
final List<DownloadTreeNode> trackNodes = [];
for (final trackEntry in tracks) {
final globalKey = trackEntry.key;
final download = trackEntry.value;
final meta = widget.metadata[globalKey];
if (meta == null) continue;
trackNodes.add(
DownloadTreeNode(
key: globalKey,
title: meta.title ?? globalKey,
type: DownloadNodeType.track,
progress: download.progressPercent,
status: download.status,
metadata: meta,
downloadProgress: download,
),
);
}
if (trackNodes.isEmpty) continue;
// Sort tracks by disc then track number
trackNodes.sort((a, b) {
final byDisc = (a.metadata?.discNumber ?? 1).compareTo(b.metadata?.discNumber ?? 1);
if (byDisc != 0) return byDisc;
return (a.metadata?.trackNumber ?? 0).compareTo(b.metadata?.trackNumber ?? 0);
});
final albumProgress = trackNodes.map((e) => e.progress).reduce((a, b) => a + b) / trackNodes.length;
final albumStatus = _determineAggregateStatus(trackNodes.map((e) => e.status).toList());
albums.add(
DownloadTreeNode(
key: albumKey,
title: albumNodeTitle,
type: DownloadNodeType.album,
progress: albumProgress,
status: albumStatus,
children: trackNodes,
),
);
}
// Sort shows, albums, and movies by status and title
_sortNodesByStatusAndTitle(shows);
_sortNodesByStatusAndTitle(albums);
_sortNodesByStatusAndTitle(movies);
// Combine movies, shows, and albums
return [...movies, ...shows, ...albums];
}
/// Determine aggregate status from child statuses
/// Priority: downloading > queued > paused > completed > failed
DownloadStatus _determineAggregateStatus(List<DownloadStatus> statuses) {
if (statuses.isEmpty) return DownloadStatus.queued;
if (statuses.any((s) => s == DownloadStatus.downloading)) {
return DownloadStatus.downloading;
}
if (statuses.any((s) => s == DownloadStatus.queued)) {
return DownloadStatus.queued;
}
if (statuses.any((s) => s == DownloadStatus.paused)) {
return DownloadStatus.paused;
}
if (statuses.any((s) => s == DownloadStatus.failed)) {
return DownloadStatus.failed;
}
return DownloadStatus.completed;
}
/// Compare statuses for sorting (downloading first, then queued, etc.)
int _compareByStatus(DownloadStatus a, DownloadStatus b) {
const statusOrder = {
DownloadStatus.downloading: 0,
DownloadStatus.queued: 1,
DownloadStatus.paused: 2,
DownloadStatus.completed: 3,
DownloadStatus.failed: 4,
DownloadStatus.cancelled: 5,
};
return (statusOrder[a] ?? 99).compareTo(statusOrder[b] ?? 99);
}
/// Sort nodes by status (downloading first) then by title
void _sortNodesByStatusAndTitle(List<DownloadTreeNode> nodes) {
nodes.sort((a, b) {
final statusCompare = _compareByStatus(a.status, b.status);
if (statusCompare != 0) return statusCompare;
return a.title.compareTo(b.title);
});
}
/// Flatten the tree into a list of visible nodes with their depths
List<_FlatNode> _flattenTree(List<DownloadTreeNode> nodes, [int depth = 0]) {
final List<_FlatNode> result = [];
for (final node in nodes) {
result.add(_FlatNode(node: node, depth: depth));
// Add children if node is expanded
if (_expandedNodes.contains(node.key) && node.hasChildren) {
result.addAll(_flattenTree(node.children, depth + 1));
}
}
return result;
}
/// Toggle node expansion
void _toggleExpansion(String key) {
setState(() {
if (_expandedNodes.contains(key)) {
_expandedNodes.remove(key);
} else {
_expandedNodes.add(key);
}
});
}
/// Build a tree item widget
Widget _buildTreeItem(DownloadTreeNode node, int depth, {bool isFirst = false}) {
return _DownloadTreeItem(
node: node,
depth: depth,
isExpanded: _expandedNodes.contains(node.key),
onToggleExpansion: () => _toggleExpansion(node.key),
onPause: widget.onPause,
onResume: widget.onResume,
onRetry: widget.onRetry,
onCancel: widget.onCancel,
onDelete: widget.onDelete,
onNavigateLeft: widget.onNavigateLeft,
onBack: widget.onBack,
rowFocusNode: isFirst ? _firstItemFocusNode : null,
autofocus: isFirst && !widget.suppressAutoFocus,
pauseAllChildren: _pauseAllChildren,
resumeAllChildren: _resumeAllChildren,
deleteAllChildren: _deleteAllChildren,
);
}
/// Pause all active (downloading and queued) children of a container node
void _pauseAllChildren(DownloadTreeNode node) {
final keys = _leafKeys(
node,
where: (leaf) => leaf.status == DownloadStatus.downloading || leaf.status == DownloadStatus.queued,
);
for (final key in keys) {
widget.onPause?.call(key);
}
}
/// Resume all paused children of a container node
void _resumeAllChildren(DownloadTreeNode node) {
final keys = _leafKeys(node, where: (leaf) => leaf.status == DownloadStatus.paused);
for (final key in keys) {
widget.onResume?.call(key);
}
}
/// Delete all children of a container node via the container's globalKey
/// so deleteDownload's transitive show/season path cleans up all maps.
void _deleteAllChildren(DownloadTreeNode node) {
final containerKey = resolveDownloadContainerGlobalKey(node, widget.metadata);
if (containerKey != null) {
widget.onDelete?.call(containerKey);
return;
}
// Container globalKey unresolvable; fall back to per-leaf delete.
for (final key in _leafKeys(node)) {
widget.onDelete?.call(key);
}
}
/// Get the keys of every leaf below a container node, in tree order.
/// [where] filters which leaves are collected; unset collects all of them.
List<String> _leafKeys(DownloadTreeNode node, {bool Function(DownloadTreeNode leaf)? where}) {
final List<String> keys = [];
for (final child in node.children) {
if (child.hasChildren) {
keys.addAll(_leafKeys(child, where: where));
} else if (where == null || where(child)) {
keys.add(child.key);
}
}
return keys;
}
}
/// Tree-node keys for shows/seasons aren't provider globalKeys; reconstruct
/// from any leaf episode's serverId + grandparentId/parentId.
@visibleForTesting
String? resolveDownloadContainerGlobalKey(DownloadTreeNode node, Map<String, MediaItem> metadata) {
final firstLeafKey = _firstLeafKey(node);
if (firstLeafKey == null) return null;
final firstLeafMeta = metadata[firstLeafKey];
final serverId = firstLeafMeta?.serverId;
if (serverId == null) return null;
switch (node.type) {
case DownloadNodeType.show:
final showRatingKey = firstLeafMeta!.grandparentId;
if (showRatingKey == null) return null;
return buildGlobalKey(ServerId(serverId), showRatingKey);
case DownloadNodeType.season:
case DownloadNodeType.album:
final parentRatingKey = firstLeafMeta!.parentId;
if (parentRatingKey == null) return null;
return buildGlobalKey(ServerId(serverId), parentRatingKey);
case DownloadNodeType.episode:
case DownloadNodeType.movie:
case DownloadNodeType.track:
return null;
}
}
String? _firstLeafKey(DownloadTreeNode node) {
for (final child in node.children) {
if (child.hasChildren) {
final result = _firstLeafKey(child);
if (result != null) return result;
} else {
return child.key;
}
}
return null;
}
/// Helper class to store a node with its depth in the flattened tree
class _FlatNode {
final DownloadTreeNode node;
final int depth;
const _FlatNode({required this.node, required this.depth});
}
/// A single action button of a tree row: the guards that decide which actions
/// exist live in one place ([_DownloadTreeItemState._actions]), so the focus
/// node count and the rendered buttons can never disagree.
typedef _RowAction = ({IconData icon, String tooltip, VoidCallback onPressed});
/// A single tree item with focusable row content and action buttons
class _DownloadTreeItem extends StatefulWidget {
final DownloadTreeNode node;
final int depth;
final bool isExpanded;
final VoidCallback onToggleExpansion;
final void Function(String globalKey)? onPause;
final void Function(String globalKey)? onResume;
final void Function(String globalKey)? onRetry;
final void Function(String globalKey)? onCancel;
final void Function(String globalKey)? onDelete;
final VoidCallback? onNavigateLeft;
final VoidCallback? onBack;
final FocusNode? rowFocusNode;
final bool autofocus;
final void Function(DownloadTreeNode) pauseAllChildren;
final void Function(DownloadTreeNode) resumeAllChildren;
final void Function(DownloadTreeNode) deleteAllChildren;
const _DownloadTreeItem({
required this.node,
required this.depth,
required this.isExpanded,
required this.onToggleExpansion,
this.onPause,
this.onResume,
this.onRetry,
this.onCancel,
this.onDelete,
this.onNavigateLeft,
this.onBack,
this.rowFocusNode,
this.autofocus = false,
required this.pauseAllChildren,
required this.resumeAllChildren,
required this.deleteAllChildren,
});
@override
State<_DownloadTreeItem> createState() => _DownloadTreeItemState();
}
class _DownloadTreeItemState extends State<_DownloadTreeItem> {
/// Treat downloading items with no progress/speed as effectively queued
/// (they're waiting in background_downloader's HoldingQueue).
DownloadStatus get _effectiveStatus {
if (widget.node.status == DownloadStatus.downloading &&
widget.node.progress == 0 &&
(widget.node.downloadProgress?.speed ?? 0) == 0) {
return DownloadStatus.queued;
}
return widget.node.status;
}
// Focus node for row content (only created if not provided externally)
FocusNode? _ownedRowFocusNode;
// Focus nodes for action buttons (up to 3 buttons max)
final List<FocusNode> _buttonFocusNodes = [];
FocusNode get _rowFocusNode => widget.rowFocusNode ?? _ownedRowFocusNode!;
@override
void initState() {
super.initState();
_initRowFocusNode();
_initButtonFocusNodes();
}
@override
void didUpdateWidget(_DownloadTreeItem oldWidget) {
super.didUpdateWidget(oldWidget);
// Reinitialize focus nodes if action count changed
if (_actions().length != _buttonFocusNodes.length) {
_disposeButtonFocusNodes();
_initButtonFocusNodes();
}
}
void _initRowFocusNode() {
if (widget.rowFocusNode == null) {
_ownedRowFocusNode = FocusNode(debugLabel: 'download_row_${widget.node.key}');
}
}
void _initButtonFocusNodes() {
final actionCount = _actions().length;
for (int i = 0; i < actionCount; i++) {
_buttonFocusNodes.add(FocusNode(debugLabel: 'download_action_$i'));
}
}
void _disposeButtonFocusNodes() {
for (final node in _buttonFocusNodes) {
node.dispose();
}
_buttonFocusNodes.clear();
}
@override
void dispose() {
_ownedRowFocusNode?.dispose();
_disposeButtonFocusNodes();
super.dispose();
}
void _focusFirstButton() {
if (_buttonFocusNodes.isNotEmpty) {
_buttonFocusNodes.first.requestFocus();
}
}
void _focusRow() {
_rowFocusNode.requestFocus();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final canExpand = widget.node.hasChildren;
final actions = _actions();
return Padding(
padding: .only(left: widget.depth * 16.0),
child: FocusableWrapper(
focusNode: _rowFocusNode,
autofocus: widget.autofocus,
onSelect: canExpand ? widget.onToggleExpansion : null,
onNavigateLeft: widget.onNavigateLeft,
onNavigateRight: actions.isNotEmpty ? _focusFirstButton : null,
onBack: widget.onBack,
borderRadius: 8.0,
disableScale: true,
useBackgroundFocus: true,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: canExpand ? widget.onToggleExpansion : null,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
// Row content
Expanded(child: _buildRowContent(theme, canExpand)),
// Action buttons
if (actions.isNotEmpty)
Row(
mainAxisSize: .min,
children: [for (int i = 0; i < actions.length; i++) _buildActionButton(actions[i], i)],
),
],
),
),
),
),
);
}
Widget _buildRowContent(ThemeData theme, bool canExpand) {
return Row(
children: [
// Expand/collapse icon
if (canExpand)
AppIcon(widget.isExpanded ? Symbols.expand_more_rounded : Symbols.chevron_right_rounded, fill: 1, size: 20)
else
const SizedBox(width: 20),
const SizedBox(width: 8),
// Status icon
DownloadStatusIcon(status: _effectiveStatus, size: 20),
const SizedBox(width: 12),
// Title and info
Expanded(
child: Column(
crossAxisAlignment: .start,
mainAxisSize: .min,
children: [
Text(
widget.node.title,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: canExpand ? FontWeight.w600 : FontWeight.normal,
),
maxLines: 1,
overflow: .ellipsis,
),
if (canExpand) ...[
const SizedBox(height: 4),
Text(
_getNodeSummary(),
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurface.withValues(alpha: 0.6)),
),
],
// Progress bar for active downloads
if (_effectiveStatus == DownloadStatus.downloading) ...[
const SizedBox(height: 8),
LinearProgressIndicator(
value: widget.node.progress,
backgroundColor: theme.colorScheme.surfaceContainerHighest,
),
if (widget.node.downloadProgress != null) ...[
const SizedBox(height: 4),
Text(
'${(widget.node.progress * 100).toStringAsFixed(1)}% - ${widget.node.downloadProgress!.speedFormatted}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
),
),
],
],
// Queued label
if (_effectiveStatus == DownloadStatus.queued) ...[
const SizedBox(height: 4),
Text(
t.downloads.downloadQueued,
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurface.withValues(alpha: 0.5)),
),
],
// Error message for failed downloads
if (_effectiveStatus == DownloadStatus.failed && widget.node.downloadProgress?.errorMessage != null) ...[
const SizedBox(height: 4),
Text(
widget.node.downloadProgress!.errorMessage!,
style: theme.textTheme.bodySmall?.copyWith(color: Colors.red.withValues(alpha: 0.8)),
maxLines: 2,
overflow: .ellipsis,
),
],
],
),
),
],
);
}
String _getNodeSummary() {
final total = widget.node.children.length;
final completed = widget.node.completedChildrenCount;
return '$completed/$total completed';
}
/// The actions this row offers, in render order. Single source of truth:
/// both the button widgets and the focus nodes sizing come from this list,
/// so they cannot drift apart. Uses the raw node status, not
/// [_effectiveStatus] (which only remaps the row content).
List<_RowAction> _actions() {
final status = widget.node.status;
final isContainer =
widget.node.type == DownloadNodeType.show ||
widget.node.type == DownloadNodeType.season ||
widget.node.type == DownloadNodeType.album;
final actions = <_RowAction>[];
if (isContainer) {
// Pause all button
if ((status == DownloadStatus.downloading || status == DownloadStatus.queued) && widget.onPause != null) {
actions.add((
icon: Symbols.pause_rounded,
tooltip: t.downloads.pauseAll,
onPressed: () => widget.pauseAllChildren(widget.node),
));
}
// Resume all button
if (status == DownloadStatus.paused && widget.onResume != null) {
actions.add((
icon: Symbols.play_arrow_rounded,
tooltip: t.downloads.resumeAll,
onPressed: () => widget.resumeAllChildren(widget.node),
));
}
// Delete all button
if (widget.onDelete != null) {
actions.add((
icon: Symbols.delete_sweep_rounded,
tooltip: t.downloads.deleteAll,
onPressed: () async {
if (await _confirmDelete()) widget.deleteAllChildren(widget.node);
},
));
}
return actions;
}
final globalKey = widget.node.key;
// Pause button for downloading items
if (status == DownloadStatus.downloading && widget.onPause != null) {
actions.add((icon: Symbols.pause_rounded, tooltip: t.common.pause, onPressed: () => widget.onPause!(globalKey)));
}
// Resume button for paused items
if (status == DownloadStatus.paused && widget.onResume != null) {
actions.add((
icon: Symbols.play_arrow_rounded,
tooltip: t.common.resume,
onPressed: () => widget.onResume!(globalKey),
));
}
// Cancel button for downloading/queued items
if ((status == DownloadStatus.downloading || status == DownloadStatus.queued) && widget.onCancel != null) {
actions.add((
icon: Symbols.close_rounded,
tooltip: t.common.cancel,
onPressed: () => widget.onCancel!(globalKey),
));
}
// Retry button for failed items
if (status == DownloadStatus.failed && widget.onRetry != null) {
actions.add((
icon: Symbols.refresh_rounded,
tooltip: t.downloads.retryDownload,
onPressed: () => widget.onRetry!(globalKey),
));
}
// Delete button for completed/failed/cancelled items
if ((status == DownloadStatus.completed || status == DownloadStatus.failed || status == DownloadStatus.cancelled) &&
widget.onDelete != null) {
actions.add((
icon: Symbols.delete_rounded,
tooltip: t.common.delete,
onPressed: () async {
if (await _confirmDelete()) widget.onDelete!(globalKey);
},
));
}
return actions;
}
Future<bool> _confirmDelete() {
return showDeleteConfirmation(
context,
title: t.downloads.deleteDownload,
message: t.downloads.deleteConfirm(title: widget.node.title),
);
}
Widget _buildActionButton(_RowAction action, int buttonIndex) {
final isFirst = buttonIndex == 0;
final isLast = buttonIndex == _buttonFocusNodes.length - 1;
return FocusableWrapper(
focusNode: _buttonFocusNodes[buttonIndex],
onSelect: action.onPressed,
onNavigateLeft: isFirst ? _focusRow : () => _buttonFocusNodes[buttonIndex - 1].requestFocus(),
onNavigateRight: isLast ? null : () => _buttonFocusNodes[buttonIndex + 1].requestFocus(),
onBack: widget.onBack,
borderRadius: 20.0,
disableScale: true,
useBackgroundFocus: true,
autoScroll: false,
child: Tooltip(
message: action.tooltip,
child: GestureDetector(
onTap: action.onPressed,
child: Padding(padding: const EdgeInsets.all(8.0), child: AppIcon(action.icon, fill: 1, size: 20)),
),
),
);
}
}