mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-12 03:14:09 +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
@@ -1,18 +1,37 @@
|
||||
import 'package:flutter/material.dart' show Tooltip;
|
||||
import 'package:flutter/material.dart' show Tooltip, TooltipState;
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// The single product tooltip primitive.
|
||||
///
|
||||
/// Flutter's tooltip is used intentionally because the shadcn tooltip does not
|
||||
/// currently inherit Gopeed's mixed Shadcn/Material theme consistently.
|
||||
class AppTooltip extends StatelessWidget {
|
||||
const AppTooltip({super.key, required this.message, required this.child});
|
||||
class AppTooltip extends StatefulWidget {
|
||||
const AppTooltip({super.key, required this.message, required this.child, this.focusable = false});
|
||||
|
||||
final String message;
|
||||
final Widget child;
|
||||
final bool focusable;
|
||||
|
||||
@override
|
||||
State<AppTooltip> createState() => _AppTooltipState();
|
||||
}
|
||||
|
||||
class _AppTooltipState extends State<AppTooltip> {
|
||||
final _tooltipKey = GlobalKey<TooltipState>();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Tooltip(message: message, child: child);
|
||||
final tooltip = Tooltip(key: _tooltipKey, message: widget.message, child: widget.child);
|
||||
if (!widget.focusable) return tooltip;
|
||||
return Focus(
|
||||
onFocusChange: (focused) {
|
||||
if (focused) {
|
||||
_tooltipKey.currentState?.ensureTooltipVisible();
|
||||
} else {
|
||||
Tooltip.dismissAllToolTips();
|
||||
}
|
||||
},
|
||||
child: tooltip,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ class FileTreeItem<T> {
|
||||
required this.name,
|
||||
required this.size,
|
||||
required this.data,
|
||||
this.directoryOnly = false,
|
||||
this.namespace = '',
|
||||
});
|
||||
|
||||
final String key;
|
||||
@@ -23,6 +25,10 @@ class FileTreeItem<T> {
|
||||
final String name;
|
||||
final int size;
|
||||
final T data;
|
||||
final bool directoryOnly;
|
||||
|
||||
/// Keeps directories from independent resources distinct without adding a visible root.
|
||||
final String namespace;
|
||||
}
|
||||
|
||||
class FileTreeNode<T> {
|
||||
@@ -61,6 +67,7 @@ class FileTreeView<T> extends StatefulWidget {
|
||||
this.headerLeading,
|
||||
this.leadingBuilder,
|
||||
this.iconBuilder,
|
||||
this.iconWidgetBuilder,
|
||||
this.trailingBuilder,
|
||||
this.onNodePressed,
|
||||
this.keyPrefix = 'file-tree',
|
||||
@@ -71,12 +78,15 @@ class FileTreeView<T> extends StatefulWidget {
|
||||
this.contentTextStyle,
|
||||
this.iconSize = 18,
|
||||
this.initialExpandedDepth,
|
||||
this.directoryLabels = const {},
|
||||
});
|
||||
|
||||
final List<FileTreeItem<T>> items;
|
||||
final Map<String, String> directoryLabels;
|
||||
final Widget? headerLeading;
|
||||
final FileTreeNodeWidgetBuilder<T>? leadingBuilder;
|
||||
final FileTreeNodeIconBuilder<T>? iconBuilder;
|
||||
final FileTreeNodeWidgetBuilder<T>? iconWidgetBuilder;
|
||||
final FileTreeNodeWidgetBuilder<T>? trailingBuilder;
|
||||
final ValueChanged<FileTreeNode<T>>? onNodePressed;
|
||||
final String keyPrefix;
|
||||
@@ -115,7 +125,11 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProv
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_nodes = _buildTree(widget.items, expandedDepth: widget.initialExpandedDepth);
|
||||
_nodes = _buildTree(
|
||||
widget.items,
|
||||
expandedDepth: widget.initialExpandedDepth,
|
||||
directoryLabels: widget.directoryLabels,
|
||||
);
|
||||
_allFoldersExpanded = _foldersAreExpanded(_nodes);
|
||||
_transitionController = AnimationController(vsync: this, duration: _treeTransitionDuration, value: 1)
|
||||
..addStatusListener(_onTransitionStatus);
|
||||
@@ -127,8 +141,12 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProv
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (identical(widget.items, oldWidget.items)) return;
|
||||
|
||||
final expandedKeys = _expandedFolderKeys(_nodes);
|
||||
var nodes = _restoreExpandedFolders(_buildTree(widget.items), expandedKeys);
|
||||
// During a collapse animation, the pending tree holds the user's target state.
|
||||
final expandedKeys = _expandedFolderKeys(_pendingCollapsedNodes ?? _nodes);
|
||||
var nodes = _restoreExpandedFolders(
|
||||
_buildTree(widget.items, directoryLabels: widget.directoryLabels),
|
||||
expandedKeys,
|
||||
);
|
||||
nodes = _sortNodes(nodes, _sortColumn, _sortDirection);
|
||||
_nodes = nodes;
|
||||
_allFoldersExpanded = _foldersAreExpanded(nodes);
|
||||
@@ -171,7 +189,7 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProv
|
||||
label,
|
||||
() => _measureLabelWidth(label, style: labelStyle, textDirection: textDirection, textScaler: textScaler),
|
||||
);
|
||||
final nameContentWidth = _widestLabelWidth(widget.items, measureLabel);
|
||||
final nameContentWidth = _widestLabelWidth(_nodes, measureLabel);
|
||||
final fixedNameChrome =
|
||||
_treeNodeExpandChrome + widget.iconSize + 4 + (widget.leadingBuilder == null ? 0 : _treeLeadingChrome);
|
||||
|
||||
@@ -179,7 +197,7 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProv
|
||||
builder: (context, constraints) {
|
||||
final treeWidth = constraints.hasBoundedWidth ? constraints.maxWidth : _treeFallbackWidth;
|
||||
final horizontalOverflow = _horizontalOverflow(
|
||||
widget.items,
|
||||
_nodes,
|
||||
baseNameViewport:
|
||||
treeWidth - _treeHorizontalPadding - trailingColumnWidth - _treeTrailingGap - fixedNameChrome,
|
||||
depthIndent: treeItemInset * 3,
|
||||
@@ -283,14 +301,15 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProv
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (leading != null) ...[leading, const SizedBox(width: 8)],
|
||||
Icon(
|
||||
widget.iconBuilder?.call(data) ??
|
||||
(data.isFolder
|
||||
? (visuallyExpanded ? BootstrapIcons.folder2Open : BootstrapIcons.folder2)
|
||||
: _fileIcon(data.label)),
|
||||
size: widget.iconSize,
|
||||
color: palette.textSecondary,
|
||||
),
|
||||
widget.iconWidgetBuilder?.call(context, data) ??
|
||||
Icon(
|
||||
widget.iconBuilder?.call(data) ??
|
||||
(data.isFolder
|
||||
? (visuallyExpanded ? BootstrapIcons.folder2Open : BootstrapIcons.folder2)
|
||||
: _fileIcon(data.label)),
|
||||
size: widget.iconSize,
|
||||
color: palette.textSecondary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
@@ -873,7 +892,11 @@ class _BuildNode<T> {
|
||||
}
|
||||
}
|
||||
|
||||
List<TreeNode<FileTreeNode<T>>> _buildTree<T>(List<FileTreeItem<T>> items, {int? expandedDepth}) {
|
||||
List<TreeNode<FileTreeNode<T>>> _buildTree<T>(
|
||||
List<FileTreeItem<T>> items, {
|
||||
int? expandedDepth,
|
||||
Map<String, String> directoryLabels = const {},
|
||||
}) {
|
||||
final root = <_BuildNode<T>>[];
|
||||
final folders = <String, _BuildNode<T>>{};
|
||||
var originalIndex = 0;
|
||||
@@ -885,8 +908,13 @@ List<TreeNode<FileTreeNode<T>>> _buildTree<T>(List<FileTreeItem<T>> items, {int?
|
||||
|
||||
for (final part in parts) {
|
||||
currentPath = currentPath.isEmpty ? part : '$currentPath/$part';
|
||||
final folder = folders.putIfAbsent(currentPath, () {
|
||||
final node = _BuildNode<T>(key: 'folder:$currentPath', label: part, originalIndex: originalIndex++);
|
||||
final directoryKey = item.namespace.isEmpty ? currentPath : '${item.namespace}:$currentPath';
|
||||
final folder = folders.putIfAbsent(directoryKey, () {
|
||||
final node = _BuildNode<T>(
|
||||
key: 'folder:$directoryKey',
|
||||
label: directoryLabels[directoryKey] ?? part,
|
||||
originalIndex: originalIndex++,
|
||||
);
|
||||
if (parent == null) {
|
||||
root.add(node);
|
||||
} else {
|
||||
@@ -897,6 +925,8 @@ List<TreeNode<FileTreeNode<T>>> _buildTree<T>(List<FileTreeItem<T>> items, {int?
|
||||
parent = folder;
|
||||
}
|
||||
|
||||
if (item.directoryOnly) continue;
|
||||
|
||||
final fileNode = _BuildNode<T>(
|
||||
key: 'file:${item.key}',
|
||||
label: item.name,
|
||||
@@ -945,10 +975,10 @@ List<TreeNode<FileTreeNode<T>>> _sortNodes<T>(
|
||||
sorted.sort((a, b) {
|
||||
final left = a.data;
|
||||
final right = b.data;
|
||||
if (left.isFolder != right.isFolder) return left.isFolder ? -1 : 1;
|
||||
if (column == null || direction == _FileTreeSortDirection.none) {
|
||||
return left.originalIndex.compareTo(right.originalIndex);
|
||||
}
|
||||
if (left.isFolder != right.isFolder) return left.isFolder ? -1 : 1;
|
||||
final comparison = switch (column) {
|
||||
_FileTreeSortColumn.name => left.label.toLowerCase().compareTo(right.label.toLowerCase()),
|
||||
_FileTreeSortColumn.size => left.size.compareTo(right.size),
|
||||
@@ -994,7 +1024,7 @@ Set<String> _expandedFolderKeys<T>(Iterable<TreeNode<FileTreeNode<T>>> nodes) {
|
||||
final keys = <String>{};
|
||||
void collect(TreeNode<FileTreeNode<T>> node) {
|
||||
final item = node as TreeItemNode<FileTreeNode<T>>;
|
||||
if (item.children.isNotEmpty && item.expanded) keys.add(item.data.key);
|
||||
if (item.data.isFolder && item.expanded) keys.add(item.data.key);
|
||||
for (final child in item.children) {
|
||||
collect(child);
|
||||
}
|
||||
@@ -1016,7 +1046,7 @@ List<TreeNode<FileTreeNode<T>>> _restoreExpandedFolders<T>(
|
||||
final children = _restoreExpandedFolders(item.children, expandedKeys);
|
||||
return item
|
||||
.updateChildren(children)
|
||||
.updateState(expanded: item.children.isNotEmpty && expandedKeys.contains(item.data.key));
|
||||
.updateState(expanded: item.data.isFolder && expandedKeys.contains(item.data.key));
|
||||
})
|
||||
.toList(growable: false);
|
||||
}
|
||||
@@ -1036,13 +1066,12 @@ String _extensionOf(String name) {
|
||||
return name.substring(index + 1).toLowerCase();
|
||||
}
|
||||
|
||||
double _widestLabelWidth<T>(List<FileTreeItem<T>> items, double Function(String label) measureLabel) {
|
||||
double _widestLabelWidth<T>(List<TreeNode<FileTreeNode<T>>> nodes, double Function(String label) measureLabel) {
|
||||
var widest = _treeMinimumNameContentWidth;
|
||||
for (final item in items) {
|
||||
widest = math.max(widest, measureLabel(item.name));
|
||||
for (final segment in item.path.split(RegExp(r'[/\\]+'))) {
|
||||
if (segment.isNotEmpty) widest = math.max(widest, measureLabel(segment));
|
||||
}
|
||||
for (final node in nodes) {
|
||||
final item = node as TreeItemNode<FileTreeNode<T>>;
|
||||
widest = math.max(widest, measureLabel(item.data.label));
|
||||
widest = math.max(widest, _widestLabelWidth(item.children, measureLabel));
|
||||
}
|
||||
return widest;
|
||||
}
|
||||
@@ -1063,20 +1092,27 @@ double _measureLabelWidth(
|
||||
}
|
||||
|
||||
double _horizontalOverflow<T>(
|
||||
List<FileTreeItem<T>> items, {
|
||||
List<TreeNode<FileTreeNode<T>>> nodes, {
|
||||
required double baseNameViewport,
|
||||
required double depthIndent,
|
||||
required double Function(String label) measureLabel,
|
||||
int depth = 0,
|
||||
}) {
|
||||
var overflow = 0.0;
|
||||
for (final item in items) {
|
||||
final segments = item.path.split(RegExp(r'[/\\]+')).where((segment) => segment.isNotEmpty).toList();
|
||||
for (var depth = 0; depth < segments.length; depth++) {
|
||||
final viewport = math.max(_treeMinimumNameViewport, baseNameViewport - (depth * depthIndent));
|
||||
overflow = math.max(overflow, measureLabel(segments[depth]) - viewport);
|
||||
}
|
||||
final fileViewport = math.max(_treeMinimumNameViewport, baseNameViewport - (segments.length * depthIndent));
|
||||
overflow = math.max(overflow, measureLabel(item.name) - fileViewport);
|
||||
final viewport = math.max(_treeMinimumNameViewport, baseNameViewport - depth * depthIndent);
|
||||
for (final node in nodes) {
|
||||
final item = node as TreeItemNode<FileTreeNode<T>>;
|
||||
overflow = math.max(overflow, measureLabel(item.data.label) - viewport);
|
||||
overflow = math.max(
|
||||
overflow,
|
||||
_horizontalOverflow(
|
||||
item.children,
|
||||
baseNameViewport: baseNameViewport,
|
||||
depthIndent: depthIndent,
|
||||
measureLabel: measureLabel,
|
||||
depth: depth + 1,
|
||||
),
|
||||
);
|
||||
}
|
||||
return math.max(0, overflow);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user