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:
Levi committed 2026-09-17 00:03:36 +08:00
commit a83e3a2b84
272 files changed
+17212 -1065

No files matched your search

@@ -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;
@@ -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,