mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-15 04:43:41 +00:00
Merge remote-tracking branch 'origin/main' into codex/fix-bt-file-selection
# Conflicts: # ui/flutter/test/features/tasks/domain/task_record_test.dart
This commit is contained in:
commit
a83e3a2b84
272 files changed
+17212
-1065
No files matched your search
@@ -31,12 +31,15 @@ import '../../../../shared/widgets/app_choice_segmented_control.dart';
|
||||
import '../../../../shared/widgets/app_http_headers_editor.dart';
|
||||
import '../../../../shared/widgets/app_number_input.dart';
|
||||
import '../../../../shared/widgets/app_path_picker_field.dart';
|
||||
import '../../../../shared/widgets/app_primary_button.dart';
|
||||
import '../../../../shared/widgets/app_text_field.dart';
|
||||
import '../../../../shared/widgets/app_tooltip.dart';
|
||||
import '../../../../shared/widgets/app_toast.dart';
|
||||
import '../../../../l10n/l10n.dart';
|
||||
import '../../../../util/util.dart';
|
||||
import '../../application/pending_create_task.dart';
|
||||
import '../../application/task_list_navigation.dart';
|
||||
import '../widgets/history_search_field.dart';
|
||||
import '../widgets/create_task_action_buttons.dart';
|
||||
import '../widgets/resolve_file_tree.dart';
|
||||
|
||||
class CreateTaskWindowPage extends ConsumerStatefulWidget {
|
||||
@@ -86,17 +89,28 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
String _configuredDownloadDirectory = '';
|
||||
String _fileDataUri = '';
|
||||
bool _programmaticUrlChange = false;
|
||||
String _lastUrlText = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_urlController.addListener(_handleUrlChanged);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final task = widget.initialTask ?? ref.read(pendingCreateTaskProvider);
|
||||
_applyInitialTask(task);
|
||||
if (widget.initialTask == null) {
|
||||
ref.read(pendingCreateTaskProvider.notifier).clear();
|
||||
}
|
||||
if (widget.windowController == null && widget.initialTask == null) {
|
||||
// Navigating to /create again reuses this page, so consume subsequent
|
||||
// browser/share requests as well as the initial pending task.
|
||||
ref.listenManual(pendingCreateTaskProvider, (_, next) {
|
||||
if (next == null) return;
|
||||
ref.read(pendingCreateTaskProvider.notifier).clear();
|
||||
_applyInitialTask(next);
|
||||
});
|
||||
}
|
||||
if (task == null || _urlController.text.trim().isEmpty) {
|
||||
unawaited(_loadClipboardUrl());
|
||||
}
|
||||
@@ -161,10 +175,15 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
}
|
||||
|
||||
void _handleUrlChanged() {
|
||||
if (!_programmaticUrlChange && _fileDataUri.isNotEmpty) {
|
||||
_fileDataUri = '';
|
||||
final urlText = _urlController.text;
|
||||
if (urlText != _lastUrlText) {
|
||||
_lastUrlText = urlText;
|
||||
_renameController.clear();
|
||||
if (!_programmaticUrlChange && _fileDataUri.isNotEmpty) {
|
||||
_fileDataUri = '';
|
||||
}
|
||||
}
|
||||
_recognizeMagnetUri(_urlController.text.trim());
|
||||
_recognizeMagnetUri(urlText.trim());
|
||||
}
|
||||
|
||||
void _applyInitialTask(CreateTask? task) {
|
||||
@@ -173,6 +192,13 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
final opts = task.opts;
|
||||
setState(() {
|
||||
if (req?.url.isNotEmpty == true) {
|
||||
_fileDataUri = '';
|
||||
_renameController.clear();
|
||||
_httpMethod = 'GET';
|
||||
_httpBody = '';
|
||||
_replaceHttpHeaders(const {'User-Agent': '', 'Cookie': '', 'Referer': ''});
|
||||
_trackersController.clear();
|
||||
_protocolTab = 0;
|
||||
_urlController.text = req!.url;
|
||||
_initialRawUrl = req.rawUrl;
|
||||
_initialLabels = req.labels == null ? null : Map<String, String>.of(req.labels!);
|
||||
@@ -202,9 +228,6 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
}
|
||||
}
|
||||
if (opts != null) {
|
||||
if (opts.name.isNotEmpty) {
|
||||
_renameController.text = opts.name;
|
||||
}
|
||||
if (opts.path.isNotEmpty) {
|
||||
_directoryController.text = opts.path;
|
||||
}
|
||||
@@ -257,7 +280,12 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
final page = Scaffold(
|
||||
backgroundColor: palette.sideBg,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(top: AppWindowChrome.reservesHeaderInset ? AppDesignTokens.windowHeaderHeight : 0),
|
||||
key: const ValueKey('create-task-safe-content'),
|
||||
padding: EdgeInsets.only(
|
||||
top: AppWindowChrome.reservesHeaderInset
|
||||
? AppDesignTokens.windowHeaderHeight
|
||||
: MediaQuery.paddingOf(context).top,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
@@ -300,7 +328,8 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 120,
|
||||
child: TextField(
|
||||
child: AppTextField(
|
||||
key: const ValueKey('create-task-url-input'),
|
||||
controller: _urlController,
|
||||
hintText: context.l10n.pasteDownloadLinks,
|
||||
keyboardType: TextInputType.multiline,
|
||||
@@ -346,18 +375,12 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
const SizedBox(height: 16),
|
||||
_FormRow(
|
||||
label: context.l10n.connections,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SizedBox(
|
||||
width: AppDesignTokens.settingsNumberControlWidth,
|
||||
child: AppNumberInput(
|
||||
fieldKey: const ValueKey('create-task-connections-input'),
|
||||
controller: _connectionsController,
|
||||
min: 1,
|
||||
max: 256,
|
||||
hintText: context.l10n.enterCount,
|
||||
),
|
||||
),
|
||||
child: AppNumberInput(
|
||||
fieldKey: const ValueKey('create-task-connections-input'),
|
||||
controller: _connectionsController,
|
||||
min: 1,
|
||||
max: 256,
|
||||
hintText: context.l10n.enterCount,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@@ -372,10 +395,6 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
pickerKey: const ValueKey('create-task-directory-picker'),
|
||||
controller: _directoryController,
|
||||
hintText: context.l10n.chooseDownloadDirectory,
|
||||
filled: true,
|
||||
border: Border.all(color: palette.border),
|
||||
borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
onChanged: (_) {
|
||||
if (_asDefaultPath) {
|
||||
setState(() => _asDefaultPath = false);
|
||||
@@ -645,7 +664,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
label: context.l10n.trackers,
|
||||
child: SizedBox(
|
||||
height: 96,
|
||||
child: TextField(
|
||||
child: AppTextField(
|
||||
controller: _trackersController,
|
||||
hintText: context.l10n.oneTrackerPerLine,
|
||||
keyboardType: TextInputType.multiline,
|
||||
@@ -670,31 +689,27 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(24, 12, 24, 20),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(top: BorderSide(color: palette.border)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SecondaryButton(
|
||||
onPressed: _creating ? null : _closeWindow,
|
||||
child: SizedBox(width: 68, child: Center(child: Text(context.l10n.cancel))),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
AppPrimaryButton(
|
||||
onPressed: _creating ? null : _confirm,
|
||||
child: SizedBox(
|
||||
width: 68,
|
||||
child: Center(
|
||||
child: _creating
|
||||
? const SizedBox.square(dimension: 14, child: CircularProgressIndicator())
|
||||
: Text(context.l10n.confirm),
|
||||
),
|
||||
SafeArea(
|
||||
top: false,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(24, 12, 24, 20),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(top: BorderSide(color: palette.border)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CreateTaskActionButtons(
|
||||
submitting: _creating,
|
||||
onCancel: _closeWindow,
|
||||
onSubmit: _confirm,
|
||||
cancelLabel: context.l10n.cancel,
|
||||
submitLabel: context.l10n.confirm,
|
||||
cancelButtonKey: const ValueKey('create-task-cancel-button'),
|
||||
submitButtonKey: const ValueKey('create-task-confirm-button'),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -713,6 +728,21 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
await windowManager.close();
|
||||
}
|
||||
|
||||
Future<void> _finishCreation() async {
|
||||
if (widget.windowController != null) {
|
||||
// Navigation is best-effort after a successful submission. A navigation
|
||||
// failure must not leave a completed form available for resubmission.
|
||||
try {
|
||||
await ref.read(appCapabilitiesProvider).navigation.showDownloadingTasks();
|
||||
} catch (error) {
|
||||
debugPrint('Unable to select downloading tasks: $error');
|
||||
}
|
||||
} else {
|
||||
ref.read(taskListNavigationProvider.notifier).showDownloading();
|
||||
}
|
||||
await _closeWindow();
|
||||
}
|
||||
|
||||
Future<void> _loadDefaults() async {
|
||||
try {
|
||||
final config = await ref.read(gopeedServiceProvider).getConfig();
|
||||
@@ -757,15 +787,10 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
}
|
||||
|
||||
Future<void> _pickTorrentFile() async {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: const ['torrent'],
|
||||
withData: kIsWeb,
|
||||
);
|
||||
final file = result?.files.single;
|
||||
final file = await FilePicker.pickFile(type: FileType.custom, allowedExtensions: const ['torrent']);
|
||||
if (file == null) return;
|
||||
|
||||
await _applyTorrentFile(name: file.name, filePath: file.path, bytes: file.bytes);
|
||||
await _applyTorrentFile(name: file.name, filePath: file.path, readBytes: file.readAsBytes);
|
||||
}
|
||||
|
||||
Future<void> _handleDroppedTorrent(DropDoneDetails details) async {
|
||||
@@ -844,7 +869,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
);
|
||||
}),
|
||||
);
|
||||
await _closeWindow();
|
||||
await _finishCreation();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -852,7 +877,6 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
final options = _buildOptions();
|
||||
final result = await ref.read(gopeedServiceProvider).resolve(ResolveTask(req: request, opts: options));
|
||||
if (!mounted) return;
|
||||
_syncResolvedName(result);
|
||||
final created = await _showResolveDialog(request, result);
|
||||
if (!created) {
|
||||
if (mounted) {
|
||||
@@ -860,7 +884,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
await _closeWindow();
|
||||
await _finishCreation();
|
||||
} catch (error) {
|
||||
if (mounted) {
|
||||
_showToast(error.toString());
|
||||
@@ -1137,19 +1161,15 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
SecondaryButton(
|
||||
onPressed: submitting ? null : () => closeOverlay(dialogContext, false),
|
||||
child: SizedBox(width: 68, child: Center(child: Text(dialogContext.l10n.cancel))),
|
||||
),
|
||||
AppPrimaryButton(
|
||||
onPressed: submitting ? null : submit,
|
||||
child: SizedBox(
|
||||
width: 68,
|
||||
child: Center(
|
||||
child: submitting
|
||||
? const SizedBox.square(dimension: 14, child: CircularProgressIndicator())
|
||||
: Text(dialogContext.l10n.create),
|
||||
),
|
||||
flutter.Flexible(
|
||||
child: CreateTaskActionButtons(
|
||||
submitting: submitting,
|
||||
onCancel: () => closeOverlay(dialogContext, false),
|
||||
onSubmit: submit,
|
||||
cancelLabel: dialogContext.l10n.cancel,
|
||||
submitLabel: dialogContext.l10n.createAction,
|
||||
cancelButtonKey: const ValueKey('resolve-cancel-button'),
|
||||
submitButtonKey: const ValueKey('resolve-create-button'),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -1192,7 +1212,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
width: contentWidth,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Text(dialogContext.l10n.createHistory)),
|
||||
Expanded(child: Text(dialogContext.l10n.historyLinks)),
|
||||
AppTooltip(
|
||||
message: dialogContext.l10n.clearHistory,
|
||||
child: GhostButton(
|
||||
@@ -1231,20 +1251,9 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
height: screenSize.height < 560 ? screenSize.height * 0.55 : 360,
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 38,
|
||||
child: TextField(
|
||||
key: const ValueKey('create-history-filter'),
|
||||
controller: filterController,
|
||||
placeholder: Text(
|
||||
dialogContext.l10n.searchHistory,
|
||||
style: TextStyle(color: palette.searchHint, fontSize: 13),
|
||||
),
|
||||
features: [
|
||||
InputFeature.leading(Icon(Icons.search_rounded, size: 15, color: palette.textMuted)),
|
||||
],
|
||||
onChanged: (value) => setDialogState(() => filterQuery = value),
|
||||
),
|
||||
HistorySearchField(
|
||||
controller: filterController,
|
||||
onChanged: (value) => setDialogState(() => filterQuery = value),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Expanded(
|
||||
@@ -1312,12 +1321,6 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
filterController.dispose();
|
||||
}
|
||||
|
||||
void _syncResolvedName(ResolveResult result) {
|
||||
if (_renameController.text.trim().isEmpty && result.res.name.trim().isNotEmpty) {
|
||||
_renameController.text = result.res.name;
|
||||
}
|
||||
}
|
||||
|
||||
void _setUrlText(String value, {String fileDataUri = ''}) {
|
||||
_programmaticUrlChange = true;
|
||||
_urlController.text = value;
|
||||
@@ -1659,17 +1662,12 @@ class _WindowTextField extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = AppPalette.of(context);
|
||||
return TextField(
|
||||
return AppTextField(
|
||||
controller: controller,
|
||||
hintText: hintText,
|
||||
keyboardType: keyboardType,
|
||||
inputFormatters: inputFormatters,
|
||||
obscureText: obscureText,
|
||||
filled: true,
|
||||
border: Border.all(color: palette.border),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter/widgets.dart' as flutter;
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
import '../../../../shared/theme/app_design_tokens.dart';
|
||||
import '../../../../shared/widgets/app_loading_button.dart';
|
||||
|
||||
class CreateTaskActionButtons extends StatelessWidget {
|
||||
const CreateTaskActionButtons({
|
||||
super.key,
|
||||
required this.submitting,
|
||||
required this.onCancel,
|
||||
required this.onSubmit,
|
||||
required this.cancelLabel,
|
||||
required this.submitLabel,
|
||||
this.cancelButtonKey,
|
||||
this.submitButtonKey,
|
||||
});
|
||||
|
||||
final bool submitting;
|
||||
final VoidCallback onCancel;
|
||||
final VoidCallback onSubmit;
|
||||
final String cancelLabel;
|
||||
final String submitLabel;
|
||||
final Key? cancelButtonKey;
|
||||
final Key? submitButtonKey;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return flutter.Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SecondaryButton(
|
||||
key: cancelButtonKey,
|
||||
onPressed: submitting ? null : onCancel,
|
||||
child: SizedBox(
|
||||
width: _actionContentWidth,
|
||||
child: Center(child: Text(cancelLabel)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppDesignTokens.space12),
|
||||
AppLoadingButton(
|
||||
key: submitButtonKey,
|
||||
onPressed: onSubmit,
|
||||
loading: submitting,
|
||||
variant: AppLoadingButtonVariant.primary,
|
||||
child: SizedBox(
|
||||
width: _actionContentWidth,
|
||||
child: Center(child: Text(submitLabel)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const _actionContentWidth = 68.0;
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
import '../../../../l10n/l10n.dart';
|
||||
import '../../../../shared/theme/app_design_tokens.dart';
|
||||
import '../../../../shared/theme/app_palette.dart';
|
||||
import '../../../../shared/widgets/app_text_field.dart';
|
||||
|
||||
class HistorySearchField extends StatelessWidget {
|
||||
const HistorySearchField({super.key, required this.controller, required this.onChanged});
|
||||
|
||||
final TextEditingController controller;
|
||||
final ValueChanged<String> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = AppPalette.of(context);
|
||||
const textStyle = TextStyle(fontSize: 13);
|
||||
return ConstrainedBox(
|
||||
// Allow large accessibility text to grow instead of clipping the line.
|
||||
constraints: const BoxConstraints(minHeight: 38),
|
||||
child: AppTextField(
|
||||
key: const ValueKey('create-history-filter'),
|
||||
controller: controller,
|
||||
style: textStyle,
|
||||
padding: AppDesignTokens.compactTextFieldPadding,
|
||||
placeholder: Text(
|
||||
context.l10n.searchHistory,
|
||||
// Match the editable text metrics so the cursor and hint align.
|
||||
style: textStyle.copyWith(color: palette.searchHint),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
features: [InputFeature.leading(Icon(Icons.search_rounded, size: 15, color: palette.textMuted))],
|
||||
onChanged: onChanged,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,8 @@ class _ResolveFileTreeState extends State<ResolveFileTree> {
|
||||
child: FileTreeView<int>(
|
||||
items: _treeItems,
|
||||
keyPrefix: 'resolve-tree',
|
||||
rowHeight: _resolveTreeRowHeight,
|
||||
contentTextStyle: _resolveFileNameStyle,
|
||||
headerLeading: ExcludeFocus(
|
||||
child: Checkbox(
|
||||
state: allSelected
|
||||
@@ -187,42 +189,48 @@ class _TreeFooter extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = AppPalette.of(context);
|
||||
final filters = _FileTypeFilterGroup(
|
||||
videoActive: activeTypeFilters.contains(_FileTypeFilter.video),
|
||||
audioActive: activeTypeFilters.contains(_FileTypeFilter.audio),
|
||||
imageActive: activeTypeFilters.contains(_FileTypeFilter.image),
|
||||
onSelectVideo: onSelectVideo,
|
||||
onSelectAudio: onSelectAudio,
|
||||
onSelectImage: onSelectImage,
|
||||
final filters = KeyedSubtree(
|
||||
key: const ValueKey('resolve-tree-filters'),
|
||||
child: _FileTypeFilterGroup(
|
||||
videoActive: activeTypeFilters.contains(_FileTypeFilter.video),
|
||||
audioActive: activeTypeFilters.contains(_FileTypeFilter.audio),
|
||||
imageActive: activeTypeFilters.contains(_FileTypeFilter.image),
|
||||
onSelectVideo: onSelectVideo,
|
||||
onSelectAudio: onSelectAudio,
|
||||
onSelectImage: onSelectImage,
|
||||
),
|
||||
);
|
||||
final stats = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
final statsStyle = TextStyle(color: palette.textSecondary, fontSize: 12, fontWeight: FontWeight.w600);
|
||||
final selectedLabel = context.l10n.selectedCount(selectedCount, totalCount);
|
||||
final sizeLabel = unknownSize ? context.l10n.unknownSize : Util.fmtByte(selectedSize);
|
||||
final baseline = MediaQuery.textScalerOf(context).scale(statsStyle.fontSize!);
|
||||
final stats = Wrap(
|
||||
key: const ValueKey('resolve-tree-selection-stats'),
|
||||
alignment: WrapAlignment.end,
|
||||
spacing: AppDesignTokens.space8,
|
||||
runSpacing: AppDesignTokens.space4,
|
||||
crossAxisAlignment: WrapCrossAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.selectedCount(selectedCount, totalCount),
|
||||
style: TextStyle(color: palette.textSecondary, fontSize: 12, fontWeight: FontWeight.w600),
|
||||
Baseline(
|
||||
baseline: baseline,
|
||||
baselineType: TextBaseline.alphabetic,
|
||||
child: Text(key: const ValueKey('resolve-tree-selected-count'), selectedLabel, style: statsStyle),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
unknownSize ? context.l10n.unknownSize : Util.fmtByte(selectedSize),
|
||||
style: TextStyle(color: palette.textSecondary, fontSize: 12, fontWeight: FontWeight.w600),
|
||||
Baseline(
|
||||
baseline: baseline,
|
||||
baselineType: TextBaseline.alphabetic,
|
||||
child: Text(key: const ValueKey('resolve-tree-selected-size'), sizeLabel, style: statsStyle),
|
||||
),
|
||||
],
|
||||
);
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
if (constraints.maxWidth < 360) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Align(alignment: Alignment.centerLeft, child: filters),
|
||||
const SizedBox(height: 4),
|
||||
Align(alignment: Alignment.centerRight, child: stats),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Row(crossAxisAlignment: CrossAxisAlignment.center, children: [filters, const Spacer(), stats]);
|
||||
},
|
||||
|
||||
return OverflowBar(
|
||||
key: const ValueKey('resolve-tree-footer'),
|
||||
spacing: AppDesignTokens.space8,
|
||||
alignment: MainAxisAlignment.spaceBetween,
|
||||
overflowAlignment: OverflowBarAlignment.end,
|
||||
overflowSpacing: AppDesignTokens.space4,
|
||||
children: [filters, stats],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -288,7 +296,11 @@ class _FilterDivider extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(width: 1, height: 18, child: ColoredBox(color: color));
|
||||
return SizedBox(
|
||||
width: _fileTypeFilterDividerWidth,
|
||||
height: 18,
|
||||
child: ColoredBox(color: color),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,7 +315,7 @@ class _FilterButton extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final palette = AppPalette.of(context);
|
||||
return SizedBox(
|
||||
width: 40,
|
||||
width: _fileTypeFilterButtonWidth,
|
||||
height: 28,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(color: active ? palette.filterActiveBg : null),
|
||||
@@ -336,3 +348,7 @@ extension on _FileTypeFilter {
|
||||
const _videoExts = <String>{'mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v'};
|
||||
const _audioExts = <String>{'mp3', 'flac', 'wav', 'aac', 'm4a', 'ogg', 'ape'};
|
||||
const _imageExts = <String>{'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'};
|
||||
const _resolveTreeRowHeight = 36.0;
|
||||
const _resolveFileNameStyle = TextStyle(fontSize: 14, height: 1.25);
|
||||
const _fileTypeFilterButtonWidth = 40.0;
|
||||
const _fileTypeFilterDividerWidth = 1.0;
|
||||
+10
-3
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart' show Colors, Icons;
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
|
||||
import '../../../../../core/window/app_window_chrome.dart';
|
||||
import '../../../../../shared/theme/app_design_tokens.dart';
|
||||
import '../../../../../shared/theme/app_palette.dart';
|
||||
import '../../../domain/task_record.dart';
|
||||
@@ -224,7 +225,7 @@ class _ProgressContent extends StatelessWidget {
|
||||
fillColor: fillColor,
|
||||
highlightStartColor: fillColor,
|
||||
highlightEndColor: fillColor,
|
||||
shimmer: task.status == TaskStatus.downloading,
|
||||
shimmer: task.status == TaskStatus.downloading && !task.waiting,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
TaskCardFooter(task: task, isError: isError),
|
||||
@@ -237,7 +238,10 @@ class _ProgressContent extends StatelessWidget {
|
||||
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 => [(Icons.folder_open_outlined, null), (Icons.delete_outline, onDelete)],
|
||||
TaskStatus.completed => [
|
||||
if (AppWindowChrome.isDesktopWindow) (Icons.folder_open_outlined, null),
|
||||
(Icons.delete_outline, onDelete),
|
||||
],
|
||||
};
|
||||
|
||||
return actions
|
||||
@@ -306,7 +310,10 @@ class _CompletedContent extends StatelessWidget {
|
||||
TaskStatus.downloading => [(Icons.pause, null), (Icons.close, onDelete)],
|
||||
TaskStatus.paused => [(Icons.play_arrow, null), (Icons.close, onDelete)],
|
||||
TaskStatus.failed => [(Icons.refresh, null), (Icons.delete_outline, onDelete)],
|
||||
TaskStatus.completed => [(Icons.folder_open_outlined, onReveal), (Icons.delete_outline, onDelete)],
|
||||
TaskStatus.completed => [
|
||||
if (AppWindowChrome.isDesktopWindow) (Icons.folder_open_outlined, onReveal),
|
||||
(Icons.delete_outline, onDelete),
|
||||
],
|
||||
};
|
||||
|
||||
return actions
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart' show Colors, Icons;
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
|
||||
import '../../../../../core/window/app_window_chrome.dart';
|
||||
import '../../../../../shared/theme/app_design_tokens.dart';
|
||||
import '../../../../../shared/theme/app_palette.dart';
|
||||
import '../../../domain/task_record.dart';
|
||||
@@ -148,7 +149,7 @@ class _TaskCardMobileState extends State<TaskCardMobile> {
|
||||
fillColor: fillColor,
|
||||
highlightStartColor: fillColor,
|
||||
highlightEndColor: fillColor,
|
||||
shimmer: task.status == TaskStatus.downloading,
|
||||
shimmer: task.status == TaskStatus.downloading && !task.waiting,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
TaskCardFooter(task: task, isError: task.status == TaskStatus.failed),
|
||||
@@ -171,7 +172,10 @@ class _TaskCardMobileState extends State<TaskCardMobile> {
|
||||
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 => [(Icons.folder_open_outlined, widget.onReveal), (Icons.delete_outline, widget.onDelete)],
|
||||
TaskStatus.completed => [
|
||||
if (AppWindowChrome.isDesktopWindow) (Icons.folder_open_outlined, widget.onReveal),
|
||||
(Icons.delete_outline, widget.onDelete),
|
||||
],
|
||||
};
|
||||
|
||||
return actions
|
||||
|
||||
@@ -95,14 +95,18 @@ class TaskCardFooter extends StatelessWidget {
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
task.total != null ? '${task.downloaded} / ${task.total}' : context.l10n.unknownSize,
|
||||
task.total != null
|
||||
? '${task.downloaded} / ${task.total}'
|
||||
: task.downloadedBytes == 0
|
||||
? context.l10n.unknownSize
|
||||
: task.downloaded,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: leftColor, fontSize: 10, height: 1),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
if (task.speed != null && !isError) ...[
|
||||
if (task.speed != null) ...[
|
||||
_TaskTransferSpeed(icon: Icons.south, value: task.speed!, color: metaColor),
|
||||
const SizedBox(width: 8),
|
||||
TickDivider(color: palette.progressTrack),
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
import 'package:flutter/material.dart' show Icons;
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../../../../l10n/l10n.dart';
|
||||
import '../../../../shared/theme/app_design_tokens.dart';
|
||||
import '../../../../shared/widgets/app_primary_button.dart';
|
||||
|
||||
/// Shared creation action for task headers and empty-list guidance.
|
||||
class TaskCreateButton extends StatelessWidget {
|
||||
const TaskCreateButton({super.key, required this.onPressed, this.minHeight = 0, this.compact = false});
|
||||
|
||||
final VoidCallback onPressed;
|
||||
final double minHeight;
|
||||
final bool compact;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: minHeight),
|
||||
child: AppPrimaryButton(
|
||||
onPressed: onPressed,
|
||||
size: compact ? shad.ButtonSize.small : shad.ButtonSize.normal,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.add, size: compact ? 16 : null),
|
||||
SizedBox(width: compact ? AppDesignTokens.space4 : AppDesignTokens.space8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
context.l10n.create,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: compact ? const TextStyle(fontSize: 13) : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform, kIsWeb;
|
||||
import 'package:flutter/material.dart' show Colors, Icons;
|
||||
import 'package:flutter/material.dart' show Colors, Icons, SelectionArea;
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
@@ -9,6 +9,7 @@ import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
import '../../../../shared/theme/app_design_tokens.dart';
|
||||
import '../../../../shared/theme/app_palette.dart';
|
||||
import '../../../../shared/widgets/app_copy_icon_button.dart';
|
||||
import '../../../../shared/widgets/app_text_field.dart';
|
||||
import '../../../../shared/widgets/app_tooltip.dart';
|
||||
import '../../../../shared/widgets/detail/app_detail_surface.dart';
|
||||
import '../../../../core/utils/text_wrap.dart';
|
||||
@@ -121,7 +122,7 @@ class _TaskDetailsViewState extends ConsumerState<TaskDetailsView> {
|
||||
? AppDesignTokens.taskDetailsMobilePadding
|
||||
: AppDesignTokens.taskDetailsDesktopPadding;
|
||||
final runtimeStatus = _tabIndex == 1 ? ref.watch(taskRuntimeStatusProvider(widget.task.id)).value : null;
|
||||
return Column(
|
||||
final content = Column(
|
||||
children: [
|
||||
Container(
|
||||
height: 48,
|
||||
@@ -203,6 +204,7 @@ class _TaskDetailsViewState extends ConsumerState<TaskDetailsView> {
|
||||
),
|
||||
],
|
||||
);
|
||||
return SelectionArea(key: const ValueKey('task-details-selection-area'), child: content);
|
||||
}
|
||||
|
||||
bool get _canOpenStoragePath {
|
||||
@@ -512,7 +514,7 @@ class _EditableUrlBlock extends StatelessWidget {
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 76,
|
||||
child: shad.TextField(
|
||||
child: AppTextField(
|
||||
controller: controller,
|
||||
minLines: null,
|
||||
maxLines: null,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
import '../../../../l10n/l10n.dart';
|
||||
import '../../../../shared/theme/app_palette.dart';
|
||||
import '../../../../shared/widgets/app_tooltip.dart';
|
||||
|
||||
/// Keeps the action inside the localized sentence, including RTL word order.
|
||||
class TaskEmptyCreateHint extends StatelessWidget {
|
||||
const TaskEmptyCreateHint({super.key, required this.onCreateTask});
|
||||
|
||||
final VoidCallback onCreateTask;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const actionMarker = '\uFFFC';
|
||||
final parts = context.l10n.emptyTaskCreateHint(actionMarker).split(actionMarker);
|
||||
return Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(text: parts.first.trimRight()),
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: AppTooltip(
|
||||
message: context.l10n.create,
|
||||
child: Semantics(
|
||||
label: context.l10n.create,
|
||||
child: SizedBox.square(
|
||||
dimension: 30,
|
||||
child: IconButton.outline(
|
||||
key: const ValueKey('tasks-empty-create-button'),
|
||||
density: ButtonDensity.compact,
|
||||
onPressed: onCreateTask,
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
TextSpan(text: parts.last.trimLeft()),
|
||||
],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: AppPalette.of(context).textMuted, fontSize: 13),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,45 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../../../../shared/theme/app_palette.dart';
|
||||
import '../../../../shared/theme/app_design_tokens.dart';
|
||||
import 'task_empty_create_hint.dart';
|
||||
import '../../../../l10n/l10n.dart';
|
||||
|
||||
class TaskEmptyState extends StatelessWidget {
|
||||
const TaskEmptyState({super.key, this.message});
|
||||
const TaskEmptyState({super.key, this.message, this.onCreateTask});
|
||||
|
||||
final String? message;
|
||||
final VoidCallback? onCreateTask;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = AppPalette.of(context);
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 360),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CustomPaint(
|
||||
key: const ValueKey('task-empty-illustration'),
|
||||
size: const Size(148, 108),
|
||||
painter: _TaskEmptyIllustrationPainter(palette: palette),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Text(
|
||||
message ?? context.l10n.emptyTaskList,
|
||||
style: TextStyle(color: palette.textPrimary, fontSize: 17, fontWeight: FontWeight.w700),
|
||||
),
|
||||
],
|
||||
child: SingleChildScrollView(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 360),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CustomPaint(
|
||||
key: const ValueKey('task-empty-illustration'),
|
||||
size: const Size(148, 108),
|
||||
painter: _TaskEmptyIllustrationPainter(palette: palette),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Text(
|
||||
message ?? context.l10n.emptyTaskList,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: palette.textPrimary, fontSize: 17, fontWeight: FontWeight.w700),
|
||||
),
|
||||
if (onCreateTask != null) ...[
|
||||
const SizedBox(height: AppDesignTokens.space24),
|
||||
TaskEmptyCreateHint(onCreateTask: onCreateTask!),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -109,7 +109,7 @@ class _TaskFileManagerViewState extends State<TaskFileManagerView> {
|
||||
try {
|
||||
final renderBox = actionContext.findRenderObject();
|
||||
final origin = renderBox is RenderBox ? renderBox.localToGlobal(Offset.zero) & renderBox.size : null;
|
||||
await Share.shareXFiles([XFile(_localPath(entry))], sharePositionOrigin: origin);
|
||||
await SharePlus.instance.share(ShareParams(files: [XFile(_localPath(entry))], sharePositionOrigin: origin));
|
||||
} catch (error) {
|
||||
if (mounted) showAppToast(context, error.toString(), type: AppToastType.error);
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ class _TaskDownloadProgress extends StatelessWidget {
|
||||
key: const ValueKey('task-files-total-progress'),
|
||||
value: progress,
|
||||
indeterminate: runtime == null && task.isIndeterminate,
|
||||
shimmer: task.status == TaskStatus.downloading,
|
||||
shimmer: task.status == TaskStatus.downloading && !task.waiting,
|
||||
height: 4,
|
||||
trackColor: palette.progressTrack,
|
||||
fillColor: palette.brandProgress,
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:shadcn_flutter/shadcn_flutter.dart' hide Column, Expanded, Row;
|
||||
import '../../../../shared/theme/app_palette.dart';
|
||||
import '../../../../shared/widgets/app_http_headers_editor.dart';
|
||||
import '../../../../shared/widgets/app_primary_button.dart';
|
||||
import '../../../../shared/widgets/app_text_field.dart';
|
||||
import '../../../../l10n/l10n.dart';
|
||||
import '../../domain/task_record.dart';
|
||||
|
||||
@@ -47,7 +48,7 @@ Future<TaskUrlUpdate?> showTaskUpdateUrlDialog(BuildContext context, TaskRecord
|
||||
children: [
|
||||
Text(dialogContext.l10n.downloadLink, style: TextStyle(color: palette.textPrimary, fontSize: 12)),
|
||||
const SizedBox(height: 6),
|
||||
TextField(controller: urlController),
|
||||
AppTextField(controller: urlController),
|
||||
const SizedBox(height: 18),
|
||||
AppHttpHeadersEditor(
|
||||
controller: headersController,
|
||||
|
||||
Reference in new issue
Block a user